Payment Gateway added

This commit is contained in:
Sidharth Prabhu
2026-07-24 12:06:56 +05:30
parent f8bd97229b
commit ba06c1a7a6
16609 changed files with 2707174 additions and 155 deletions

6
backend/.gitignore vendored
View File

@@ -31,3 +31,9 @@ build/
### VS Code ###
.vscode/
### Razorpay secrets (NEVER commit live keys) ###
razorpay.env
### Logs ###
*.log

View File

@@ -87,6 +87,12 @@
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<!-- Razorpay Payments -->
<dependency>
<groupId>com.razorpay</groupId>
<artifactId>razorpay-java</artifactId>
<version>1.4.8</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,8 @@
# Copy to razorpay.env (gitignored) and fill in your Razorpay live keys.
# Source before starting the backend:
# set -a && source razorpay.env && set +a && ./mvnw spring-boot:run
export RAZORPAY_KEY_ID=rzp_live_xxxxxxxx
export RAZORPAY_KEY_SECRET=your_secret_here
# Optional — set after creating a webhook in Razorpay Dashboard
# export RAZORPAY_WEBHOOK_SECRET=whsec_xxxxxxxx

22
backend/run-with-razorpay.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Start the backend with Razorpay credentials loaded from razorpay.env
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -f razorpay.env ]]; then
echo "Missing razorpay.env — copy razorpay.env.example and fill in your keys."
exit 1
fi
set -a
# shellcheck disable=SC1091
source ./razorpay.env
set +a
if [[ -z "${RAZORPAY_KEY_ID:-}" || -z "${RAZORPAY_KEY_SECRET:-}" ]]; then
echo "RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET must be set in razorpay.env"
exit 1
fi
echo "Starting backend with Razorpay key: ${RAZORPAY_KEY_ID:0:12}..."
exec ./mvnw spring-boot:run

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.");
}
}
}

View File

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

View File

@@ -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:}