Payment Gateway added
This commit is contained in:
@@ -62,6 +62,10 @@ public class SecurityConfig {
|
||||
// ── PUBLIC: Device log ingestion (ESP32 Bill-Bot devices, no JWT) ──
|
||||
.requestMatchers(HttpMethod.POST, "/api/device-logs").permitAll()
|
||||
|
||||
// ── PUBLIC: Razorpay webhook (authenticated via X-Razorpay-Signature) ──
|
||||
.requestMatchers(HttpMethod.POST, "/api/payments/webhook").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/payments/config").permitAll()
|
||||
|
||||
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
||||
|
||||
@@ -76,6 +80,9 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/wallet/topup").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/payments/create").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/payments/verify").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/payments/history").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
|
||||
|
||||
@@ -48,6 +48,9 @@ public class OrderController {
|
||||
@Autowired
|
||||
private TokenService tokenService;
|
||||
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.service.OrderPlacementService orderPlacementService;
|
||||
|
||||
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
||||
|
||||
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
||||
@@ -138,105 +141,42 @@ public class OrderController {
|
||||
order.setOrderType("POS");
|
||||
}
|
||||
|
||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
||||
}
|
||||
|
||||
// ── SECURITY: Server-side price verification ──────────────────────
|
||||
BigDecimal serverTotal = BigDecimal.ZERO;
|
||||
for (OrderItem item : order.getItems()) {
|
||||
if (item.getProductId() != null) {
|
||||
Optional<Product> productOpt = productRepository.findById(item.getProductId());
|
||||
if (productOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false, "message", "Product not found: " + item.getProductId()));
|
||||
}
|
||||
Product product = productOpt.get();
|
||||
// Use offer price if present, otherwise base price
|
||||
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||
? product.getOfferPrice() : product.getPrice();
|
||||
|
||||
// Add parcel fee if selected and parcellable
|
||||
if (item.getProductName() != null && item.getProductName().endsWith(" (Parcel)") && product.isParcellable()) {
|
||||
unitPrice = unitPrice.add(BigDecimal.valueOf(5));
|
||||
}
|
||||
|
||||
// Update item's saved price so it reflects the unitPrice + parcel fee
|
||||
item.setPrice(unitPrice);
|
||||
|
||||
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||
}
|
||||
}
|
||||
|
||||
// Allow ±5 tolerance for rounding differences
|
||||
if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
|
||||
// Customers cannot mark an order as paid via RAZORPAY without going through PaymentController
|
||||
if (!isStaff() && order.getPaymentMethod() != null
|
||||
&& !"RITZ_TOKEN".equalsIgnoreCase(order.getPaymentMethod())) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", "Price mismatch detected. Please refresh and try again.",
|
||||
"serverTotal", serverTotal,
|
||||
"clientTotal", order.getTotalAmount()
|
||||
"message", "Online payments must be completed via Razorpay checkout first."
|
||||
));
|
||||
}
|
||||
// Always use server-calculated total
|
||||
order.setTotalAmount(serverTotal);
|
||||
|
||||
// ── Stock check & update ──────────────────────────────────────────
|
||||
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
||||
requestConflicts.remove();
|
||||
|
||||
for (OrderItem item : order.getItems()) {
|
||||
Long productId = item.getProductId();
|
||||
if (productId != null) {
|
||||
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
||||
if (updatedRows == 0) {
|
||||
Product p = productRepository.findById(productId).orElse(null);
|
||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||
Map<String, Object> conflict = new HashMap<>();
|
||||
conflict.put("productId", productId);
|
||||
conflict.put("productName", item.getProductName());
|
||||
conflict.put("requested", item.getQuantity());
|
||||
conflict.put("available", left);
|
||||
stockConflicts.add(conflict);
|
||||
}
|
||||
}
|
||||
if (order.getPaymentMethod() == null || order.getPaymentMethod().isBlank()) {
|
||||
order.setPaymentMethod(isStaff() ? "CASH" : "RITZ_TOKEN");
|
||||
}
|
||||
|
||||
if (!stockConflicts.isEmpty()) {
|
||||
requestConflicts.set(stockConflicts);
|
||||
try {
|
||||
var result = orderPlacementService.placeOrder(order, true);
|
||||
if (!result.success) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("success", false);
|
||||
body.put("message", result.message);
|
||||
if (result.errorType != null) body.put("errorType", result.errorType);
|
||||
if (result.conflicts != null) body.put("conflicts", result.conflicts);
|
||||
if (result.serverTotal != null) body.put("serverTotal", result.serverTotal);
|
||||
return ResponseEntity.badRequest().body(body);
|
||||
}
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"orderNumber", result.orderNumber,
|
||||
"displayOrderId", result.displayOrderId,
|
||||
"message", "Order placed successfully"
|
||||
));
|
||||
} catch (com.rit.canteen.sales.service.OrderPlacementService.StockConflictException e) {
|
||||
requestConflicts.set(e.conflicts);
|
||||
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
|
||||
} catch (com.rit.canteen.sales.service.OrderPlacementService.InsufficientTokensException e) {
|
||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
}
|
||||
|
||||
// ── Complete Order Details ────────────────────────────────────────
|
||||
for (OrderItem item : order.getItems()) {
|
||||
item.setOrder(order);
|
||||
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
|
||||
item.setStallName("RIT Canteen");
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setCreatedAt(now);
|
||||
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
||||
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
||||
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
|
||||
|
||||
// ── Token payment ────────────────────────────────────────────────
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
try {
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
|
||||
} catch (RuntimeException e) {
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
Order savedOrder = orderRepository.save(order);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"orderNumber", savedOrder.getOrderNumber(),
|
||||
"displayOrderId", savedOrder.getDisplayOrderId(),
|
||||
"message", "Order placed successfully"
|
||||
));
|
||||
}
|
||||
|
||||
// ── CUSTOMER: own orders ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.rit.canteen.sales.model.PaymentSession;
|
||||
import com.rit.canteen.sales.repository.PaymentSessionRepository;
|
||||
import com.rit.canteen.sales.service.PaymentService;
|
||||
import com.rit.canteen.sales.service.RazorpayService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payments")
|
||||
public class PaymentController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PaymentController.class);
|
||||
|
||||
@Autowired
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Autowired
|
||||
private RazorpayService razorpayService;
|
||||
|
||||
@Autowired
|
||||
private PaymentSessionRepository paymentSessionRepository;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Create a Razorpay order for either food checkout or wallet top-up.
|
||||
*
|
||||
* Body for wallet:
|
||||
* { "purpose": "WALLET_TOPUP", "amount": 100 }
|
||||
*
|
||||
* Body for food order:
|
||||
* { "purpose": "ORDER", "order": { userId, items, totalAmount, orderType } }
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<?> createPayment(@RequestBody Map<String, Object> body) {
|
||||
try {
|
||||
Long userId = requireUserId();
|
||||
String purpose = body.get("purpose") != null ? body.get("purpose").toString().toUpperCase() : "";
|
||||
|
||||
if ("WALLET_TOPUP".equals(purpose)) {
|
||||
if (body.get("amount") == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "amount is required"));
|
||||
}
|
||||
BigDecimal amount = new BigDecimal(body.get("amount").toString());
|
||||
Map<String, Object> result = paymentService.createWalletTopupSession(userId, amount);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
if ("ORDER".equals(purpose)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> order = body.get("order") instanceof Map
|
||||
? (Map<String, Object>) body.get("order")
|
||||
: body; // allow flat body for convenience
|
||||
|
||||
// Force userId from JWT — never trust client for identity
|
||||
order.put("userId", userId);
|
||||
Map<String, Object> result = paymentService.createOrderPaymentSession(userId, order);
|
||||
if (Boolean.FALSE.equals(result.get("success"))) {
|
||||
return ResponseEntity.badRequest().body(result);
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", "purpose must be ORDER or WALLET_TOPUP"
|
||||
));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(503).body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Create payment failed", e);
|
||||
return ResponseEntity.status(500).body(Map.of(
|
||||
"success", false,
|
||||
"message", e.getMessage() != null ? e.getMessage() : "Failed to create payment"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Razorpay checkout response and fulfill (place order / credit wallet).
|
||||
* Only after this succeeds should the client show the order QR.
|
||||
*/
|
||||
@PostMapping("/verify")
|
||||
public ResponseEntity<?> verifyPayment(@RequestBody Map<String, Object> body) {
|
||||
try {
|
||||
Long userId = requireUserId();
|
||||
String orderId = str(body.get("razorpayOrderId"), body.get("razorpay_order_id"));
|
||||
String paymentId = str(body.get("razorpayPaymentId"), body.get("razorpay_payment_id"));
|
||||
String signature = str(body.get("razorpaySignature"), body.get("razorpay_signature"));
|
||||
|
||||
Map<String, Object> result = paymentService.verifyAndFulfill(userId, orderId, paymentId, signature);
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(409).body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Verify payment failed", e);
|
||||
return ResponseEntity.status(500).body(Map.of(
|
||||
"success", false,
|
||||
"message", e.getMessage() != null ? e.getMessage() : "Payment verification failed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public webhook endpoint. Configure in Razorpay Dashboard:
|
||||
* URL: https://<your-host>/api/payments/webhook
|
||||
* Events: payment.captured
|
||||
* Set RAZORPAY_WEBHOOK_SECRET to the dashboard secret.
|
||||
*/
|
||||
@PostMapping("/webhook")
|
||||
public ResponseEntity<?> webhook(
|
||||
@RequestBody String rawBody,
|
||||
@RequestHeader(value = "X-Razorpay-Signature", required = false) String signature) {
|
||||
try {
|
||||
if (razorpayService.hasWebhookSecret()) {
|
||||
if (signature == null || !razorpayService.verifyWebhookSignature(rawBody, signature)) {
|
||||
log.warn("Rejected Razorpay webhook with invalid signature");
|
||||
return ResponseEntity.status(400).body(Map.of("error", "Invalid signature"));
|
||||
}
|
||||
} else {
|
||||
log.warn("Webhook received but RAZORPAY_WEBHOOK_SECRET is not set — processing cautiously");
|
||||
}
|
||||
|
||||
JsonNode root = objectMapper.readTree(rawBody);
|
||||
String event = root.path("event").asText("");
|
||||
if ("payment.captured".equals(event) || "payment.authorized".equals(event)) {
|
||||
JsonNode entity = root.path("payload").path("payment").path("entity");
|
||||
String paymentId = entity.path("id").asText(null);
|
||||
String orderId = entity.path("order_id").asText(null);
|
||||
if (paymentId != null && orderId != null) {
|
||||
paymentService.handlePaymentCapturedWebhook(orderId, paymentId);
|
||||
}
|
||||
}
|
||||
// Always 200 so Razorpay does not retry endlessly on unknown events
|
||||
return ResponseEntity.ok(Map.of("received", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Webhook processing error", e);
|
||||
// Return 200 to avoid noisy retries for parse errors; log for ops
|
||||
return ResponseEntity.ok(Map.of("received", true, "error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payment history for the authenticated user.
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
public ResponseEntity<?> paymentHistory() {
|
||||
try {
|
||||
Long userId = requireUserId();
|
||||
List<PaymentSession> sessions = paymentSessionRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
List<Map<String, Object>> list = sessions.stream().map(s -> {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", s.getId());
|
||||
m.put("purpose", s.getPurpose().name());
|
||||
m.put("status", s.getStatus().name());
|
||||
m.put("amount", s.getAmountInr());
|
||||
m.put("razorpayOrderId", s.getRazorpayOrderId());
|
||||
m.put("razorpayPaymentId", s.getRazorpayPaymentId());
|
||||
m.put("orderNumber", s.getFulfillmentOrderNumber());
|
||||
m.put("createdAt", s.getCreatedAt());
|
||||
m.put("fulfilledAt", s.getFulfilledAt());
|
||||
m.put("failureReason", s.getFailureReason());
|
||||
return m;
|
||||
}).toList();
|
||||
return ResponseEntity.ok(list);
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(403).body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Payment history failed", e);
|
||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Failed to fetch payment history"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Health/config check for the ordering app (does not expose secret). */
|
||||
@GetMapping("/config")
|
||||
public ResponseEntity<?> config() {
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("enabled", razorpayService.isConfigured());
|
||||
if (razorpayService.isConfigured()) {
|
||||
resp.put("keyId", razorpayService.getKeyId());
|
||||
}
|
||||
return ResponseEntity.ok(resp);
|
||||
}
|
||||
|
||||
private Long requireUserId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||
Object uid = claims.get("userId");
|
||||
if (uid != null) {
|
||||
return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||
}
|
||||
}
|
||||
throw new SecurityException("Authentication required");
|
||||
}
|
||||
|
||||
private static String str(Object primary, Object fallback) {
|
||||
if (primary != null && !primary.toString().isBlank()) return primary.toString();
|
||||
if (fallback != null && !fallback.toString().isBlank()) return fallback.toString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -70,9 +70,21 @@ public class WalletController {
|
||||
return ResponseEntity.ok(userList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual / admin wallet credit.
|
||||
* Customers MUST use Razorpay via POST /api/payments/create (purpose=WALLET_TOPUP).
|
||||
* Free top-up is restricted to staff/manager/master only.
|
||||
*/
|
||||
@PostMapping("/topup")
|
||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
if (!isStaff()) {
|
||||
return ResponseEntity.status(403).body(Map.of(
|
||||
"success", false,
|
||||
"error", "Customer wallet top-up requires online payment. Use the Top Up screen (Razorpay)."
|
||||
));
|
||||
}
|
||||
|
||||
Long userId = Long.valueOf(request.get("userId").toString());
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
@@ -88,7 +100,7 @@ public class WalletController {
|
||||
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
|
||||
}
|
||||
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-STAFF-" + System.currentTimeMillis()).toString();
|
||||
User updatedUser = tokenService.topUp(userId, amount, ref);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
@@ -100,6 +112,19 @@ public class WalletController {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isStaff() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) return false;
|
||||
if (auth.getDetails() instanceof Claims claims) {
|
||||
String role = (String) claims.get("role");
|
||||
return "MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role);
|
||||
}
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals("ROLE_MASTER")
|
||||
|| a.getAuthority().equals("ROLE_MANAGER")
|
||||
|| a.getAuthority().equals("ROLE_STAFF"));
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/all")
|
||||
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
|
||||
return ResponseEntity.ok(tokenService.getAllTransactions());
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.rit.canteen.sales.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Tracks a Razorpay payment from creation through fulfillment.
|
||||
* Ensures order placement / wallet credit only happens after verified payment,
|
||||
* and is idempotent across client retries and webhooks.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payment_sessions", indexes = {
|
||||
@Index(name = "idx_payment_sessions_user", columnList = "user_id"),
|
||||
@Index(name = "idx_payment_sessions_status", columnList = "status")
|
||||
})
|
||||
public class PaymentSession {
|
||||
|
||||
public enum Purpose {
|
||||
ORDER,
|
||||
WALLET_TOPUP
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
CREATED,
|
||||
PAID,
|
||||
FULFILLED,
|
||||
FULFILLED_AS_CREDIT,
|
||||
FAILED,
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private Purpose purpose;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private Status status = Status.CREATED;
|
||||
|
||||
/** Amount in INR (rupees), not paise */
|
||||
@Column(nullable = false, precision = 12, scale = 2)
|
||||
private BigDecimal amountInr;
|
||||
|
||||
@Column(nullable = false, length = 8)
|
||||
private String currency = "INR";
|
||||
|
||||
@Column(name = "razorpay_order_id", nullable = false, unique = true, length = 64)
|
||||
private String razorpayOrderId;
|
||||
|
||||
@Column(name = "razorpay_payment_id", unique = true, length = 64)
|
||||
private String razorpayPaymentId;
|
||||
|
||||
@Column(name = "razorpay_signature", length = 256)
|
||||
private String razorpaySignature;
|
||||
|
||||
/** Serialized cart/order payload for ORDER purpose (JSON text) */
|
||||
@Column(name = "order_payload", columnDefinition = "TEXT")
|
||||
private String orderPayload;
|
||||
|
||||
@Column(name = "fulfillment_order_number", length = 64)
|
||||
private String fulfillmentOrderNumber;
|
||||
|
||||
@Column(name = "fulfillment_display_id", length = 32)
|
||||
private String fulfillmentDisplayId;
|
||||
|
||||
@Column(name = "failure_reason", length = 512)
|
||||
private String failureReason;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(name = "fulfilled_at")
|
||||
private LocalDateTime fulfilledAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (createdAt == null) createdAt = now;
|
||||
if (updatedAt == null) updatedAt = now;
|
||||
if (status == null) status = Status.CREATED;
|
||||
if (currency == null) currency = "INR";
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public Purpose getPurpose() { return purpose; }
|
||||
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
|
||||
|
||||
public Status getStatus() { return status; }
|
||||
public void setStatus(Status status) { this.status = status; }
|
||||
|
||||
public BigDecimal getAmountInr() { return amountInr; }
|
||||
public void setAmountInr(BigDecimal amountInr) { this.amountInr = amountInr; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public String getRazorpayOrderId() { return razorpayOrderId; }
|
||||
public void setRazorpayOrderId(String razorpayOrderId) { this.razorpayOrderId = razorpayOrderId; }
|
||||
|
||||
public String getRazorpayPaymentId() { return razorpayPaymentId; }
|
||||
public void setRazorpayPaymentId(String razorpayPaymentId) { this.razorpayPaymentId = razorpayPaymentId; }
|
||||
|
||||
public String getRazorpaySignature() { return razorpaySignature; }
|
||||
public void setRazorpaySignature(String razorpaySignature) { this.razorpaySignature = razorpaySignature; }
|
||||
|
||||
public String getOrderPayload() { return orderPayload; }
|
||||
public void setOrderPayload(String orderPayload) { this.orderPayload = orderPayload; }
|
||||
|
||||
public String getFulfillmentOrderNumber() { return fulfillmentOrderNumber; }
|
||||
public void setFulfillmentOrderNumber(String fulfillmentOrderNumber) { this.fulfillmentOrderNumber = fulfillmentOrderNumber; }
|
||||
|
||||
public String getFulfillmentDisplayId() { return fulfillmentDisplayId; }
|
||||
public void setFulfillmentDisplayId(String fulfillmentDisplayId) { this.fulfillmentDisplayId = fulfillmentDisplayId; }
|
||||
|
||||
public String getFailureReason() { return failureReason; }
|
||||
public void setFailureReason(String failureReason) { this.failureReason = failureReason; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
||||
public LocalDateTime getFulfilledAt() { return fulfilledAt; }
|
||||
public void setFulfilledAt(LocalDateTime fulfilledAt) { this.fulfilledAt = fulfilledAt; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.rit.canteen.sales.repository;
|
||||
|
||||
import com.rit.canteen.sales.model.PaymentSession;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface PaymentSessionRepository extends JpaRepository<PaymentSession, Long> {
|
||||
Optional<PaymentSession> findByRazorpayOrderId(String razorpayOrderId);
|
||||
Optional<PaymentSession> findByRazorpayPaymentId(String razorpayPaymentId);
|
||||
boolean existsByRazorpayPaymentId(String razorpayPaymentId);
|
||||
List<PaymentSession> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import org.springframework.stereotype.Repository;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
||||
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
||||
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
||||
|
||||
Optional<TokenTransaction> findByReferenceId(String referenceId);
|
||||
boolean existsByReferenceId(String referenceId);
|
||||
|
||||
@Query("SELECT SUM(t.amount) FROM TokenTransaction t WHERE t.type = :type")
|
||||
BigDecimal sumByType(@Param("type") TokenTransaction.TransactionType type);
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package com.rit.canteen.sales.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.rit.canteen.sales.model.Order;
|
||||
import com.rit.canteen.sales.model.OrderItem;
|
||||
import com.rit.canteen.sales.model.Product;
|
||||
import com.rit.canteen.sales.repository.OrderRepository;
|
||||
import com.rit.canteen.sales.repository.ProductRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Shared order placement used by direct Ritz-token checkout and post-payment Razorpay fulfillment.
|
||||
*/
|
||||
@Service
|
||||
public class OrderPlacementService {
|
||||
|
||||
public static class PlacementResult {
|
||||
public final boolean success;
|
||||
public final String orderNumber;
|
||||
public final String displayOrderId;
|
||||
public final String message;
|
||||
public final String errorType;
|
||||
public final List<Map<String, Object>> conflicts;
|
||||
public final BigDecimal serverTotal;
|
||||
|
||||
private PlacementResult(boolean success, String orderNumber, String displayOrderId,
|
||||
String message, String errorType, List<Map<String, Object>> conflicts,
|
||||
BigDecimal serverTotal) {
|
||||
this.success = success;
|
||||
this.orderNumber = orderNumber;
|
||||
this.displayOrderId = displayOrderId;
|
||||
this.message = message;
|
||||
this.errorType = errorType;
|
||||
this.conflicts = conflicts;
|
||||
this.serverTotal = serverTotal;
|
||||
}
|
||||
|
||||
public static PlacementResult ok(String orderNumber, String displayOrderId) {
|
||||
return new PlacementResult(true, orderNumber, displayOrderId, "Order placed successfully", null, null, null);
|
||||
}
|
||||
|
||||
public static PlacementResult fail(String message, String errorType, List<Map<String, Object>> conflicts) {
|
||||
return new PlacementResult(false, null, null, message, errorType, conflicts, null);
|
||||
}
|
||||
|
||||
public static PlacementResult priceMismatch(BigDecimal serverTotal, BigDecimal clientTotal) {
|
||||
return new PlacementResult(false, null, null,
|
||||
"Price mismatch detected. Please refresh and try again.",
|
||||
"PRICE_ERROR", null, serverTotal);
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Autowired
|
||||
private ProductRepository productRepository;
|
||||
|
||||
@Autowired
|
||||
private TokenService tokenService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Validates items and returns the server-side total. Does not mutate stock.
|
||||
*/
|
||||
public BigDecimal calculateServerTotal(List<OrderItem> items) {
|
||||
if (items == null || items.isEmpty()) {
|
||||
throw new IllegalArgumentException("Order must have items");
|
||||
}
|
||||
BigDecimal serverTotal = BigDecimal.ZERO;
|
||||
for (OrderItem item : items) {
|
||||
if (item.getProductId() == null) {
|
||||
throw new IllegalArgumentException("Each item must have a productId");
|
||||
}
|
||||
Product product = productRepository.findById(item.getProductId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + item.getProductId()));
|
||||
|
||||
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||
? product.getOfferPrice() : product.getPrice();
|
||||
|
||||
if (item.getProductName() != null && item.getProductName().endsWith(" (Parcel)") && product.isParcellable()) {
|
||||
unitPrice = unitPrice.add(BigDecimal.valueOf(5));
|
||||
}
|
||||
item.setPrice(unitPrice);
|
||||
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||
}
|
||||
return serverTotal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft stock availability check (no decrement).
|
||||
*/
|
||||
public List<Map<String, Object>> checkStock(List<OrderItem> items) {
|
||||
List<Map<String, Object>> conflicts = new ArrayList<>();
|
||||
for (OrderItem item : items) {
|
||||
if (item.getProductId() == null) continue;
|
||||
Product p = productRepository.findById(item.getProductId()).orElse(null);
|
||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||
if (left < item.getQuantity()) {
|
||||
Map<String, Object> conflict = new HashMap<>();
|
||||
conflict.put("productId", item.getProductId());
|
||||
conflict.put("productName", item.getProductName());
|
||||
conflict.put("requested", item.getQuantity());
|
||||
conflict.put("available", left);
|
||||
conflicts.add(conflict);
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places an order after payment authorization (Ritz tokens or verified Razorpay).
|
||||
*
|
||||
* Uses REQUIRES_NEW so a stock failure can roll back only the order attempt,
|
||||
* allowing the payment flow to credit the wallet as a fallback without
|
||||
* UnexpectedRollbackException on the outer transaction.
|
||||
*
|
||||
* @param deductRitzTokens if true, spends wallet tokens; if false, assumes external payment already captured
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public PlacementResult placeOrder(Order order, boolean deductRitzTokens) {
|
||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||
return PlacementResult.fail("Order must have items", "VALIDATION_ERROR", null);
|
||||
}
|
||||
|
||||
BigDecimal serverTotal;
|
||||
try {
|
||||
serverTotal = calculateServerTotal(order.getItems());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return PlacementResult.fail(e.getMessage(), "VALIDATION_ERROR", null);
|
||||
}
|
||||
|
||||
if (order.getTotalAmount() != null
|
||||
&& serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
|
||||
return PlacementResult.priceMismatch(serverTotal, order.getTotalAmount());
|
||||
}
|
||||
order.setTotalAmount(serverTotal);
|
||||
|
||||
// Stock decrement
|
||||
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
||||
for (OrderItem item : order.getItems()) {
|
||||
Long productId = item.getProductId();
|
||||
if (productId != null) {
|
||||
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
||||
if (updatedRows == 0) {
|
||||
Product p = productRepository.findById(productId).orElse(null);
|
||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||
Map<String, Object> conflict = new HashMap<>();
|
||||
conflict.put("productId", productId);
|
||||
conflict.put("productName", item.getProductName());
|
||||
conflict.put("requested", item.getQuantity());
|
||||
conflict.put("available", left);
|
||||
stockConflicts.add(conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stockConflicts.isEmpty()) {
|
||||
// Rollback transaction via exception so stock decrements reverse
|
||||
throw new StockConflictException(stockConflicts);
|
||||
}
|
||||
|
||||
for (OrderItem item : order.getItems()) {
|
||||
item.setOrder(order);
|
||||
if (item.getStallName() == null || item.getStallName().isEmpty() || "Unknown Stall".equals(item.getStallName())) {
|
||||
item.setStallName("RIT Canteen");
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setCreatedAt(now);
|
||||
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
||||
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
||||
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
|
||||
|
||||
if (deductRitzTokens && "RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
try {
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
|
||||
} catch (RuntimeException e) {
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
throw new InsufficientTokensException();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
Order saved = orderRepository.save(order);
|
||||
return PlacementResult.ok(saved.getOrderNumber(), saved.getDisplayOrderId());
|
||||
}
|
||||
|
||||
public Order buildOrderFromPayload(String json, Long userId, String paymentMethod) {
|
||||
try {
|
||||
Map<String, Object> map = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
Order order = new Order();
|
||||
order.setUserId(userId);
|
||||
order.setPaymentMethod(paymentMethod);
|
||||
order.setOrderType(map.get("orderType") != null ? map.get("orderType").toString() : "MY_ORDER");
|
||||
if (map.get("totalAmount") != null) {
|
||||
order.setTotalAmount(new BigDecimal(map.get("totalAmount").toString()));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> itemsRaw = (List<Map<String, Object>>) map.get("items");
|
||||
List<OrderItem> items = new ArrayList<>();
|
||||
if (itemsRaw != null) {
|
||||
for (Map<String, Object> ir : itemsRaw) {
|
||||
OrderItem item = new OrderItem();
|
||||
if (ir.get("productId") != null) {
|
||||
item.setProductId(Long.valueOf(ir.get("productId").toString()));
|
||||
}
|
||||
item.setProductName(ir.get("productName") != null ? ir.get("productName").toString() : null);
|
||||
if (ir.get("price") != null) {
|
||||
item.setPrice(new BigDecimal(ir.get("price").toString()));
|
||||
}
|
||||
item.setQuantity(ir.get("quantity") != null ? Integer.parseInt(ir.get("quantity").toString()) : 1);
|
||||
if (ir.get("stallId") != null && !"null".equals(String.valueOf(ir.get("stallId")))) {
|
||||
item.setStallId(Long.valueOf(ir.get("stallId").toString()));
|
||||
}
|
||||
item.setStallName(ir.get("stallName") != null ? ir.get("stallName").toString() : null);
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
order.setItems(items);
|
||||
return order;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Invalid order payload: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public String serializeOrderPayload(Map<String, Object> payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Could not serialize order payload");
|
||||
}
|
||||
}
|
||||
|
||||
public static class StockConflictException extends RuntimeException {
|
||||
public final List<Map<String, Object>> conflicts;
|
||||
public StockConflictException(List<Map<String, Object>> conflicts) {
|
||||
super("CONCURRENCY_STOCK_FAILURE");
|
||||
this.conflicts = conflicts;
|
||||
}
|
||||
}
|
||||
|
||||
public static class InsufficientTokensException extends RuntimeException {
|
||||
public InsufficientTokensException() {
|
||||
super("INSUFFICIENT_TOKENS");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package com.rit.canteen.sales.service;
|
||||
|
||||
import com.razorpay.Order;
|
||||
import com.razorpay.Payment;
|
||||
import com.rit.canteen.sales.model.OrderItem;
|
||||
import com.rit.canteen.sales.model.PaymentSession;
|
||||
import com.rit.canteen.sales.model.User;
|
||||
import com.rit.canteen.sales.repository.PaymentSessionRepository;
|
||||
import com.rit.canteen.sales.repository.TokenTransactionRepository;
|
||||
import com.rit.canteen.sales.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
|
||||
private static final BigDecimal MIN_TOPUP = new BigDecimal("50");
|
||||
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
|
||||
|
||||
@Autowired
|
||||
private RazorpayService razorpayService;
|
||||
|
||||
@Autowired
|
||||
private PaymentSessionRepository paymentSessionRepository;
|
||||
|
||||
@Autowired
|
||||
private OrderPlacementService orderPlacementService;
|
||||
|
||||
@Autowired
|
||||
private TokenService tokenService;
|
||||
|
||||
@Autowired
|
||||
private TokenTransactionRepository tokenTransactionRepository;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
public Map<String, Object> createWalletTopupSession(Long userId, BigDecimal amountInr) throws Exception {
|
||||
ensureConfigured();
|
||||
if (amountInr == null || amountInr.compareTo(MIN_TOPUP) < 0) {
|
||||
throw new IllegalArgumentException("Minimum top up amount is ₹50");
|
||||
}
|
||||
if (amountInr.compareTo(MAX_TOPUP) > 0) {
|
||||
throw new IllegalArgumentException("Maximum top up per transaction is ₹5,000");
|
||||
}
|
||||
// Normalize to 2 decimals
|
||||
amountInr = amountInr.setScale(2, RoundingMode.HALF_UP);
|
||||
if (amountInr.stripTrailingZeros().scale() > 0) {
|
||||
// Ritz tokens are whole units
|
||||
throw new IllegalArgumentException("Top up amount must be a whole number of rupees");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
String receipt = "TOP-" + userId + "-" + System.currentTimeMillis();
|
||||
Map<String, String> notes = Map.of(
|
||||
"purpose", "WALLET_TOPUP",
|
||||
"userId", String.valueOf(userId)
|
||||
);
|
||||
|
||||
Order rzOrder = razorpayService.createOrder(amountInr, receipt, notes);
|
||||
|
||||
PaymentSession session = new PaymentSession();
|
||||
session.setUserId(userId);
|
||||
session.setPurpose(PaymentSession.Purpose.WALLET_TOPUP);
|
||||
session.setStatus(PaymentSession.Status.CREATED);
|
||||
session.setAmountInr(amountInr);
|
||||
session.setCurrency("INR");
|
||||
session.setRazorpayOrderId(rzOrder.get("id"));
|
||||
session.setCreatedAt(LocalDateTime.now());
|
||||
session.setUpdatedAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
|
||||
return checkoutPayload(session, user);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createOrderPaymentSession(Long userId, Map<String, Object> orderRequest) throws Exception {
|
||||
ensureConfigured();
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
// Build temporary order for validation
|
||||
String payloadJson = orderPlacementService.serializeOrderPayload(orderRequest);
|
||||
com.rit.canteen.sales.model.Order draft =
|
||||
orderPlacementService.buildOrderFromPayload(payloadJson, userId, "RAZORPAY");
|
||||
|
||||
BigDecimal serverTotal = orderPlacementService.calculateServerTotal(draft.getItems());
|
||||
if (serverTotal.compareTo(BigDecimal.ONE) < 0) {
|
||||
throw new IllegalArgumentException("Order total must be at least ₹1");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> stockConflicts = orderPlacementService.checkStock(draft.getItems());
|
||||
if (!stockConflicts.isEmpty()) {
|
||||
Map<String, Object> err = new LinkedHashMap<>();
|
||||
err.put("success", false);
|
||||
err.put("errorType", "STOCK_ERROR");
|
||||
err.put("message", "Some items are no longer available in the requested quantity.");
|
||||
err.put("conflicts", stockConflicts);
|
||||
return err;
|
||||
}
|
||||
|
||||
// Persist validated total + items into payload
|
||||
orderRequest.put("totalAmount", serverTotal);
|
||||
List<Map<String, Object>> normalizedItems = new ArrayList<>();
|
||||
for (OrderItem item : draft.getItems()) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("productId", item.getProductId());
|
||||
m.put("productName", item.getProductName());
|
||||
m.put("price", item.getPrice());
|
||||
m.put("quantity", item.getQuantity());
|
||||
m.put("stallId", item.getStallId());
|
||||
m.put("stallName", item.getStallName());
|
||||
normalizedItems.add(m);
|
||||
}
|
||||
orderRequest.put("items", normalizedItems);
|
||||
orderRequest.put("orderType", orderRequest.getOrDefault("orderType", "MY_ORDER"));
|
||||
payloadJson = orderPlacementService.serializeOrderPayload(orderRequest);
|
||||
|
||||
String receipt = "ORD-" + userId + "-" + System.currentTimeMillis();
|
||||
Map<String, String> notes = Map.of(
|
||||
"purpose", "ORDER",
|
||||
"userId", String.valueOf(userId)
|
||||
);
|
||||
|
||||
Order rzOrder = razorpayService.createOrder(serverTotal, receipt, notes);
|
||||
|
||||
PaymentSession session = new PaymentSession();
|
||||
session.setUserId(userId);
|
||||
session.setPurpose(PaymentSession.Purpose.ORDER);
|
||||
session.setStatus(PaymentSession.Status.CREATED);
|
||||
session.setAmountInr(serverTotal);
|
||||
session.setCurrency("INR");
|
||||
session.setRazorpayOrderId(rzOrder.get("id"));
|
||||
session.setOrderPayload(payloadJson);
|
||||
session.setCreatedAt(LocalDateTime.now());
|
||||
session.setUpdatedAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
|
||||
return checkoutPayload(session, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies Razorpay checkout response and fulfills the payment session.
|
||||
* Idempotent: replaying the same payment returns the previous fulfillment result.
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> verifyAndFulfill(Long userId, String razorpayOrderId,
|
||||
String razorpayPaymentId, String razorpaySignature) throws Exception {
|
||||
ensureConfigured();
|
||||
|
||||
if (razorpayOrderId == null || razorpayPaymentId == null || razorpaySignature == null) {
|
||||
throw new IllegalArgumentException("Missing payment verification fields");
|
||||
}
|
||||
|
||||
PaymentSession session = paymentSessionRepository.findByRazorpayOrderId(razorpayOrderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unknown payment session"));
|
||||
|
||||
if (!session.getUserId().equals(userId)) {
|
||||
throw new SecurityException("Payment session does not belong to this user");
|
||||
}
|
||||
|
||||
// Idempotent success
|
||||
if (session.getStatus() == PaymentSession.Status.FULFILLED
|
||||
|| session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
|
||||
return buildFulfillmentResponse(session);
|
||||
}
|
||||
|
||||
// Another path may have already stored this payment id
|
||||
Optional<PaymentSession> byPayment = paymentSessionRepository.findByRazorpayPaymentId(razorpayPaymentId);
|
||||
if (byPayment.isPresent() && !byPayment.get().getId().equals(session.getId())) {
|
||||
throw new IllegalStateException("Payment already linked to another session");
|
||||
}
|
||||
|
||||
if (!razorpayService.verifyPaymentSignature(razorpayOrderId, razorpayPaymentId, razorpaySignature)) {
|
||||
session.setStatus(PaymentSession.Status.FAILED);
|
||||
session.setFailureReason("Invalid payment signature");
|
||||
paymentSessionRepository.save(session);
|
||||
throw new SecurityException("Payment signature verification failed");
|
||||
}
|
||||
|
||||
// Server-side confirmation with Razorpay API
|
||||
Payment payment = razorpayService.fetchPayment(razorpayPaymentId);
|
||||
if (!razorpayService.isPaymentCaptured(payment)) {
|
||||
session.setStatus(PaymentSession.Status.FAILED);
|
||||
session.setFailureReason("Payment not captured: " + payment.get("status"));
|
||||
paymentSessionRepository.save(session);
|
||||
throw new IllegalStateException("Payment has not been captured yet. Status: " + payment.get("status"));
|
||||
}
|
||||
|
||||
// Ensure payment is for this order
|
||||
String paymentOrderId = payment.get("order_id");
|
||||
if (paymentOrderId != null && !razorpayOrderId.equals(paymentOrderId)) {
|
||||
session.setStatus(PaymentSession.Status.FAILED);
|
||||
session.setFailureReason("Payment order mismatch");
|
||||
paymentSessionRepository.save(session);
|
||||
throw new SecurityException("Payment does not match Razorpay order");
|
||||
}
|
||||
|
||||
BigDecimal paidInr = razorpayService.paymentAmountInr(payment);
|
||||
if (paidInr.compareTo(session.getAmountInr()) != 0) {
|
||||
session.setStatus(PaymentSession.Status.FAILED);
|
||||
session.setFailureReason("Amount mismatch: paid=" + paidInr + " expected=" + session.getAmountInr());
|
||||
paymentSessionRepository.save(session);
|
||||
throw new SecurityException("Paid amount does not match expected amount");
|
||||
}
|
||||
|
||||
session.setRazorpayPaymentId(razorpayPaymentId);
|
||||
session.setRazorpaySignature(razorpaySignature);
|
||||
session.setStatus(PaymentSession.Status.PAID);
|
||||
paymentSessionRepository.save(session);
|
||||
|
||||
return fulfillSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven fulfillment when client disconnects after paying.
|
||||
*/
|
||||
@Transactional
|
||||
public void handlePaymentCapturedWebhook(String razorpayOrderId, String razorpayPaymentId) {
|
||||
try {
|
||||
PaymentSession session = paymentSessionRepository.findByRazorpayOrderId(razorpayOrderId).orElse(null);
|
||||
if (session == null) {
|
||||
log.warn("Webhook for unknown order {}", razorpayOrderId);
|
||||
return;
|
||||
}
|
||||
if (session.getStatus() == PaymentSession.Status.FULFILLED
|
||||
|| session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify via API
|
||||
Payment payment = razorpayService.fetchPayment(razorpayPaymentId);
|
||||
if (!razorpayService.isPaymentCaptured(payment)) {
|
||||
log.warn("Webhook payment {} not captured", razorpayPaymentId);
|
||||
return;
|
||||
}
|
||||
|
||||
BigDecimal paidInr = razorpayService.paymentAmountInr(payment);
|
||||
if (paidInr.compareTo(session.getAmountInr()) != 0) {
|
||||
log.error("Webhook amount mismatch for order {}", razorpayOrderId);
|
||||
session.setStatus(PaymentSession.Status.FAILED);
|
||||
session.setFailureReason("Webhook amount mismatch");
|
||||
paymentSessionRepository.save(session);
|
||||
return;
|
||||
}
|
||||
|
||||
session.setRazorpayPaymentId(razorpayPaymentId);
|
||||
session.setStatus(PaymentSession.Status.PAID);
|
||||
paymentSessionRepository.save(session);
|
||||
fulfillSession(session);
|
||||
} catch (Exception e) {
|
||||
log.error("Webhook fulfillment failed for order {}: {}", razorpayOrderId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> fulfillSession(PaymentSession session) throws Exception {
|
||||
if (session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP) {
|
||||
return fulfillWalletTopup(session);
|
||||
}
|
||||
return fulfillOrder(session);
|
||||
}
|
||||
|
||||
private Map<String, Object> fulfillWalletTopup(PaymentSession session) {
|
||||
String ref = "RZP-" + session.getRazorpayPaymentId();
|
||||
if (tokenTransactionRepository.existsByReferenceId(ref)) {
|
||||
session.setStatus(PaymentSession.Status.FULFILLED);
|
||||
session.setFulfilledAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
User user = userRepository.findById(session.getUserId()).orElse(null);
|
||||
Map<String, Object> resp = buildFulfillmentResponse(session);
|
||||
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
|
||||
return resp;
|
||||
}
|
||||
|
||||
User updated = tokenService.topUp(session.getUserId(), session.getAmountInr(), ref);
|
||||
session.setStatus(PaymentSession.Status.FULFILLED);
|
||||
session.setFulfilledAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
|
||||
Map<String, Object> resp = buildFulfillmentResponse(session);
|
||||
resp.put("newBalance", updated.getRitzTokenBalance());
|
||||
resp.put("message", "Successfully added " + session.getAmountInr() + " Ritz Tokens");
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Map<String, Object> fulfillOrder(PaymentSession session) {
|
||||
try {
|
||||
com.rit.canteen.sales.model.Order order = orderPlacementService.buildOrderFromPayload(
|
||||
session.getOrderPayload(), session.getUserId(), "RAZORPAY");
|
||||
order.setTotalAmount(session.getAmountInr());
|
||||
|
||||
OrderPlacementService.PlacementResult result =
|
||||
orderPlacementService.placeOrder(order, false);
|
||||
|
||||
if (!result.success) {
|
||||
// Should not normally reach here for stock (throws), but handle validation failures
|
||||
return creditAsFallback(session, result.message);
|
||||
}
|
||||
|
||||
session.setStatus(PaymentSession.Status.FULFILLED);
|
||||
session.setFulfillmentOrderNumber(result.orderNumber);
|
||||
session.setFulfillmentDisplayId(result.displayOrderId);
|
||||
session.setFulfilledAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
return buildFulfillmentResponse(session);
|
||||
} catch (OrderPlacementService.StockConflictException e) {
|
||||
log.warn("Stock conflict after payment {}; crediting wallet as fallback", session.getRazorpayPaymentId());
|
||||
return creditAsFallback(session,
|
||||
"Payment received, but some items went out of stock. ₹"
|
||||
+ session.getAmountInr() + " has been credited to your Ritz wallet.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> creditAsFallback(PaymentSession session, String reason) {
|
||||
String ref = "RZP-FALLBACK-" + session.getRazorpayPaymentId();
|
||||
if (!tokenTransactionRepository.existsByReferenceId(ref)) {
|
||||
tokenService.topUp(session.getUserId(), session.getAmountInr(), ref);
|
||||
}
|
||||
session.setStatus(PaymentSession.Status.FULFILLED_AS_CREDIT);
|
||||
session.setFailureReason(reason);
|
||||
session.setFulfilledAt(LocalDateTime.now());
|
||||
paymentSessionRepository.save(session);
|
||||
|
||||
User user = userRepository.findById(session.getUserId()).orElse(null);
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("success", true);
|
||||
resp.put("type", "WALLET_CREDIT_FALLBACK");
|
||||
resp.put("purpose", session.getPurpose().name());
|
||||
resp.put("status", session.getStatus().name());
|
||||
resp.put("message", reason);
|
||||
resp.put("creditedAmount", session.getAmountInr());
|
||||
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
|
||||
resp.put("razorpayPaymentId", session.getRazorpayPaymentId());
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildFulfillmentResponse(PaymentSession session) {
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("success", true);
|
||||
resp.put("purpose", session.getPurpose().name());
|
||||
resp.put("status", session.getStatus().name());
|
||||
resp.put("razorpayOrderId", session.getRazorpayOrderId());
|
||||
resp.put("razorpayPaymentId", session.getRazorpayPaymentId());
|
||||
resp.put("amount", session.getAmountInr());
|
||||
|
||||
if (session.getPurpose() == PaymentSession.Purpose.ORDER
|
||||
&& session.getStatus() == PaymentSession.Status.FULFILLED) {
|
||||
resp.put("type", "ORDER");
|
||||
resp.put("orderNumber", session.getFulfillmentOrderNumber());
|
||||
resp.put("displayOrderId", session.getFulfillmentDisplayId());
|
||||
resp.put("message", "Order placed successfully");
|
||||
} else if (session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP) {
|
||||
resp.put("type", "WALLET_TOPUP");
|
||||
User user = userRepository.findById(session.getUserId()).orElse(null);
|
||||
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
|
||||
resp.put("message", "Wallet top-up successful");
|
||||
} else if (session.getStatus() == PaymentSession.Status.FULFILLED_AS_CREDIT) {
|
||||
resp.put("type", "WALLET_CREDIT_FALLBACK");
|
||||
resp.put("message", session.getFailureReason());
|
||||
User user = userRepository.findById(session.getUserId()).orElse(null);
|
||||
if (user != null) resp.put("newBalance", user.getRitzTokenBalance());
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Map<String, Object> checkoutPayload(PaymentSession session, User user) {
|
||||
Map<String, Object> resp = new LinkedHashMap<>();
|
||||
resp.put("success", true);
|
||||
resp.put("paymentSessionId", session.getId());
|
||||
resp.put("keyId", razorpayService.getKeyId());
|
||||
resp.put("razorpayOrderId", session.getRazorpayOrderId());
|
||||
resp.put("amount", RazorpayService.toPaise(session.getAmountInr())); // paise for Checkout.js
|
||||
resp.put("amountInr", session.getAmountInr());
|
||||
resp.put("currency", session.getCurrency());
|
||||
resp.put("purpose", session.getPurpose().name());
|
||||
resp.put("name", "Tillo Canteen");
|
||||
resp.put("description", session.getPurpose() == PaymentSession.Purpose.WALLET_TOPUP
|
||||
? "Ritz Wallet Top-up"
|
||||
: "Food Order Payment");
|
||||
|
||||
Map<String, Object> prefill = new LinkedHashMap<>();
|
||||
prefill.put("name", user.getName() != null ? user.getName() : "");
|
||||
prefill.put("contact", user.getMobileNumber() != null ? user.getMobileNumber() : "");
|
||||
if (user.getEmail() != null) prefill.put("email", user.getEmail());
|
||||
resp.put("prefill", prefill);
|
||||
|
||||
Map<String, String> notes = new LinkedHashMap<>();
|
||||
notes.put("paymentSessionId", String.valueOf(session.getId()));
|
||||
notes.put("userId", String.valueOf(user.getId()));
|
||||
resp.put("notes", notes);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private void ensureConfigured() {
|
||||
if (!razorpayService.isConfigured()) {
|
||||
throw new IllegalStateException(
|
||||
"Razorpay is not configured on the server. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.rit.canteen.sales.service;
|
||||
|
||||
import com.razorpay.Order;
|
||||
import com.razorpay.Payment;
|
||||
import com.razorpay.RazorpayClient;
|
||||
import com.razorpay.RazorpayException;
|
||||
import com.razorpay.Utils;
|
||||
import org.json.JSONObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the official Razorpay Java SDK.
|
||||
* Secret key never leaves the server.
|
||||
*/
|
||||
@Service
|
||||
public class RazorpayService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RazorpayService.class);
|
||||
|
||||
@Value("${razorpay.key.id:}")
|
||||
private String keyId;
|
||||
|
||||
@Value("${razorpay.key.secret:}")
|
||||
private String keySecret;
|
||||
|
||||
@Value("${razorpay.webhook.secret:}")
|
||||
private String webhookSecret;
|
||||
|
||||
public boolean isConfigured() {
|
||||
return keyId != null && !keyId.isBlank()
|
||||
&& keySecret != null && !keySecret.isBlank();
|
||||
}
|
||||
|
||||
public String getKeyId() {
|
||||
return keyId;
|
||||
}
|
||||
|
||||
public boolean hasWebhookSecret() {
|
||||
return webhookSecret != null && !webhookSecret.isBlank();
|
||||
}
|
||||
|
||||
private RazorpayClient client() throws RazorpayException {
|
||||
if (!isConfigured()) {
|
||||
throw new RazorpayException("Razorpay is not configured. Set RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET.");
|
||||
}
|
||||
return new RazorpayClient(keyId, keySecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Razorpay Order for the given INR amount.
|
||||
* @param amountInr amount in rupees (e.g. 120.00)
|
||||
* @param receipt short unique receipt id (max 40 chars for Razorpay)
|
||||
* @param notes optional notes map
|
||||
*/
|
||||
public Order createOrder(BigDecimal amountInr, String receipt, Map<String, String> notes) throws RazorpayException {
|
||||
long amountPaise = toPaise(amountInr);
|
||||
if (amountPaise < 100) {
|
||||
throw new RazorpayException("Minimum payment amount is ₹1.00");
|
||||
}
|
||||
|
||||
JSONObject options = new JSONObject();
|
||||
options.put("amount", amountPaise);
|
||||
options.put("currency", "INR");
|
||||
options.put("receipt", receipt != null && receipt.length() > 40 ? receipt.substring(0, 40) : receipt);
|
||||
options.put("payment_capture", 1); // auto-capture
|
||||
|
||||
if (notes != null && !notes.isEmpty()) {
|
||||
JSONObject notesJson = new JSONObject();
|
||||
notes.forEach(notesJson::put);
|
||||
options.put("notes", notesJson);
|
||||
}
|
||||
|
||||
Order order = client().orders.create(options);
|
||||
log.info("Created Razorpay order {} for {} paise", order.get("id"), amountPaise);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies checkout signature: HMAC_SHA256(orderId|paymentId, secret)
|
||||
*/
|
||||
public boolean verifyPaymentSignature(String orderId, String paymentId, String signature) {
|
||||
if (orderId == null || paymentId == null || signature == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSONObject attributes = new JSONObject();
|
||||
attributes.put("razorpay_order_id", orderId);
|
||||
attributes.put("razorpay_payment_id", paymentId);
|
||||
attributes.put("razorpay_signature", signature);
|
||||
return Utils.verifyPaymentSignature(attributes, keySecret);
|
||||
} catch (Exception e) {
|
||||
log.warn("Payment signature verification failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies Razorpay webhook signature using X-Razorpay-Signature header.
|
||||
*/
|
||||
public boolean verifyWebhookSignature(String body, String signatureHeader) {
|
||||
if (!hasWebhookSecret() || body == null || signatureHeader == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return Utils.verifyWebhookSignature(body, signatureHeader, webhookSecret);
|
||||
} catch (Exception e) {
|
||||
log.warn("Webhook signature verification failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches payment from Razorpay API to double-check status/amount server-side.
|
||||
*/
|
||||
public Payment fetchPayment(String paymentId) throws RazorpayException {
|
||||
return client().payments.fetch(paymentId);
|
||||
}
|
||||
|
||||
public boolean isPaymentCaptured(Payment payment) {
|
||||
if (payment == null) return false;
|
||||
String status = payment.get("status");
|
||||
return "captured".equalsIgnoreCase(status) || "authorized".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
public BigDecimal paymentAmountInr(Payment payment) {
|
||||
if (payment == null) return BigDecimal.ZERO;
|
||||
Object amountObj = payment.get("amount");
|
||||
long paise;
|
||||
if (amountObj instanceof Number n) {
|
||||
paise = n.longValue();
|
||||
} else {
|
||||
paise = Long.parseLong(String.valueOf(amountObj));
|
||||
}
|
||||
return BigDecimal.valueOf(paise).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public static long toPaise(BigDecimal amountInr) {
|
||||
return amountInr
|
||||
.setScale(2, RoundingMode.HALF_UP)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.setScale(0, RoundingMode.HALF_UP)
|
||||
.longValueExact();
|
||||
}
|
||||
|
||||
/** Constant-time hex HMAC for any custom checks */
|
||||
public String hmacSha256Hex(String data, String secret) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("HMAC computation failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,3 +62,12 @@ spring.datasource.hikari.connection-timeout=20000
|
||||
logging.level.org.apache.coyote.http11.Http11InputBuffer=ERROR
|
||||
logging.level.org.apache.tomcat.util.http.parser.HttpParser=ERROR
|
||||
|
||||
# ============================================================
|
||||
# RAZORPAY — REQUIRED for online payments (ordering site + wallet top-up)
|
||||
# Set via environment variables. NEVER commit live secrets to git.
|
||||
# ============================================================
|
||||
razorpay.key.id=${RAZORPAY_KEY_ID:rzp_live_TFJG984gtJCqrs}
|
||||
razorpay.key.secret=${RAZORPAY_KEY_SECRET:LlAsZ6qzx94qgzXQu9GbQLMZ}
|
||||
# Optional but strongly recommended in production (Razorpay Dashboard → Webhooks)
|
||||
razorpay.webhook.secret=${RAZORPAY_WEBHOOK_SECRET:}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user