Ritz token page added

This commit is contained in:
Sidharth Prabhu
2026-04-20 19:48:59 +05:30
parent 2a51622450
commit c8936d8cad
32 changed files with 1948 additions and 52 deletions

View File

@@ -175,7 +175,8 @@ public class OrderController {
// 4. Token Payment Check
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
try {
tokenService.spend(order.getUser().getId(), order.getTotalAmount(), "ORD-" + displayId);
// Use userId directly for robustness
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
} catch (RuntimeException e) {
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
throw new RuntimeException("INSUFFICIENT_TOKENS");
@@ -236,9 +237,19 @@ public class OrderController {
return orderRepository.findById(id)
.map(order -> {
order.setStatus(newStatus.toUpperCase());
String oldStatus = order.getStatus();
String nextStatus = newStatus.toUpperCase();
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
}
}
order.setStatus(nextStatus);
orderRepository.save(order);
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + newStatus));
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
})
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
@@ -251,8 +262,27 @@ public class OrderController {
try {
return orderRepository.findById(id)
.map(existingOrder -> {
BigDecimal oldAmount = existingOrder.getTotalAmount();
BigDecimal newAmount = updatedOrder.getTotalAmount();
// Handle Token Adjustments for edited orders
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
int comparison = newAmount.compareTo(oldAmount);
if (comparison > 0) {
// Spend more
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
} else if (comparison < 0) {
// This is tricky for individual tokens, but we can refund the difference amount
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
// to keep unit association clean OR just record it as a topup.
// Let's do a simple balance restoration for the delta.
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
}
}
// Update basic fields
existingOrder.setTotalAmount(updatedOrder.getTotalAmount());
existingOrder.setTotalAmount(newAmount);
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
// Clear and replace items for a clean update

View File

@@ -56,4 +56,21 @@ public class WalletController {
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/transactions/all")
public ResponseEntity<List<TokenTransaction>> getAllTransactions() {
return ResponseEntity.ok(tokenService.getAllTransactions());
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
return ResponseEntity.ok(tokenService.getGlobalStats());
}
@GetMapping("/circulation")
public ResponseEntity<org.springframework.data.domain.Page<com.rit.canteen.sales.model.TokenUnit>> getCirculation(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
}
}

View File

@@ -12,8 +12,9 @@ public class TokenTransaction {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", nullable = false)
@com.fasterxml.jackson.annotation.JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
private User user;
@Column(nullable = false)

View File

@@ -0,0 +1,72 @@
package com.rit.canteen.sales.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "token_units", indexes = {
@Index(name = "idx_token_units_owner_status", columnList = "owner_id, status")
})
public class TokenUnit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String tokenHash;
@Column(name = "owner_id", nullable = true) // Can be null if not yet issued or systemic
private Long ownerId;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private TokenStatus status = TokenStatus.ACTIVE;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime spentAt;
@Column(name = "order_ref")
private String orderRef;
public enum TokenStatus {
ACTIVE,
SPENT,
REVOKED
}
public TokenUnit() {
this.createdAt = LocalDateTime.now();
this.status = TokenStatus.ACTIVE;
}
public TokenUnit(String tokenHash, Long ownerId) {
this();
this.tokenHash = tokenHash;
this.ownerId = ownerId;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTokenHash() { return tokenHash; }
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
public Long getOwnerId() { return ownerId; }
public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
public TokenStatus getStatus() { return status; }
public void setStatus(TokenStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getSpentAt() { return spentAt; }
public void setSpentAt(LocalDateTime spentAt) { this.spentAt = spentAt; }
public String getOrderRef() { return orderRef; }
public void setOrderRef(String orderRef) { this.orderRef = orderRef; }
}

View File

@@ -8,4 +8,5 @@ import java.util.List;
@Repository
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
List<TokenTransaction> findAllByOrderByTimestampDesc();
}

View File

@@ -0,0 +1,21 @@
package com.rit.canteen.sales.repository;
import com.rit.canteen.sales.model.TokenUnit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TokenUnitRepository extends JpaRepository<TokenUnit, Long> {
@Query(value = "SELECT * FROM token_units WHERE owner_id = :ownerId AND status = 'ACTIVE' ORDER BY id ASC LIMIT :amount", nativeQuery = true)
List<TokenUnit> findActiveUnits(@Param("ownerId") Long ownerId, @Param("amount") int amount);
List<TokenUnit> findByOrderRef(String orderRef);
long countByOwnerIdAndStatus(Long ownerId, TokenUnit.TokenStatus status);
org.springframework.data.domain.Page<TokenUnit> findAllByOrderByCreatedAtDesc(org.springframework.data.domain.Pageable pageable);
}

View File

@@ -15,4 +15,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')) " +
"OR u.mobileNumber LIKE CONCAT('%', :search, '%')")
org.springframework.data.domain.Page<User> findByNameOrMobileContainingIgnoreCase(String search, org.springframework.data.domain.Pageable pageable);
@jakarta.persistence.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional<User> findByIdWithLock(@org.springframework.data.repository.query.Param("id") Long id);
}

View File

@@ -29,6 +29,7 @@ public class DatabaseSeeder implements CommandLineRunner {
repairStallsSchema();
repairOrdersSchema();
repairUsersSchema();
repairTokenUnitsSchema();
repairFeedbackSchema();
repairLobColumns();
seedCategories();
@@ -66,6 +67,25 @@ public class DatabaseSeeder implements CommandLineRunner {
}
}
private void repairTokenUnitsSchema() {
System.out.println("Checking schema consistency for 'token_units' table...");
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS token_units (" +
"id BIGSERIAL PRIMARY KEY, " +
"token_hash VARCHAR(255) UNIQUE NOT NULL, " +
"owner_id BIGINT, " +
"status VARCHAR(20) NOT NULL, " +
"created_at TIMESTAMP NOT NULL, " +
"spent_at TIMESTAMP)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_token_units_owner_status ON token_units (owner_id, status)");
System.out.println("Token Units schema consistency confirmed.");
} catch (Exception e) {
System.err.println("Token Units schema repair notice: " + e.getMessage());
}
}
private void repairStallsSchema() {
System.out.println("Checking schema consistency for 'stalls' table...");
try {

View File

@@ -8,8 +8,15 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.rit.canteen.sales.model.TokenUnit;
import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class TokenService {
@@ -19,25 +26,78 @@ public class TokenService {
@Autowired
private TokenTransactionRepository transactionRepository;
@Autowired
private com.rit.canteen.sales.repository.TokenUnitRepository tokenUnitRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
public List<TokenTransaction> getTransactions(Long userId) {
return transactionRepository.findByUserIdOrderByTimestampDesc(userId);
}
public List<TokenTransaction> getAllTransactions() {
return transactionRepository.findAllByOrderByTimestampDesc();
}
public java.util.Map<String, Object> getGlobalStats() {
long totalActive = tokenUnitRepository.count(); // Actually all units
long activeUnits = tokenUnitRepository.countByOwnerIdAndStatus(null, null); // Placeholder, will fix below
List<User> users = userRepository.findAll();
BigDecimal totalBalance = users.stream()
.map(u -> u.getRitzTokenBalance() != null ? u.getRitzTokenBalance() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
java.util.Map<String, Object> stats = new java.util.HashMap<>();
stats.put("totalCirculation", totalBalance);
stats.put("activeWallets", users.stream().filter(u -> u.getRitzTokenBalance().compareTo(BigDecimal.ZERO) > 0).count());
stats.put("totalUsers", users.size());
stats.put("serializedUnitsTotal", tokenUnitRepository.count());
return stats;
}
public org.springframework.data.domain.Page<TokenUnit> getAllCirculation(int page, int size) {
return tokenUnitRepository.findAllByOrderByCreatedAtDesc(org.springframework.data.domain.PageRequest.of(page, size));
}
@Transactional
public User topUp(Long userId, BigDecimal amount, String paymentRef) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
// High-Performance Batch Insertion using JDBC
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
List<Object[]> batchArgs = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < tokenCount; i++) {
String hash = generateSecureHash();
batchArgs.add(new Object[]{hash, userId, now});
}
jdbcTemplate.batchUpdate(sql, batchArgs);
// Update cached balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
user.setRitzTokenBalance(currentBalance.add(amount));
BigDecimal newBalance = currentBalance.add(amount);
if (newBalance.compareTo(new BigDecimal("5000")) > 0) {
throw new RuntimeException("Wallet limit exceeded (Max: 5,000 Ritz Tokens)");
}
user.setRitzTokenBalance(newBalance);
User savedUser = userRepository.save(user);
TokenTransaction transaction = new TokenTransaction(
user,
amount,
TokenTransaction.TransactionType.TOPUP,
"Wallet Top Up via UPI/Card",
"Regulated Wallet Top Up (Serialized ID: " + paymentRef + ")",
paymentRef
);
transactionRepository.save(transaction);
@@ -47,7 +107,8 @@ public class TokenService {
@Transactional
public void spend(Long userId, BigDecimal amount, String orderRef) {
User user = userRepository.findById(userId)
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
User user = userRepository.findByIdWithLock(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
@@ -56,6 +117,23 @@ public class TokenService {
throw new RuntimeException("INSUFFICIENT_TOKENS");
}
int amountToSpend = amount.intValue();
// Identify individual token units to consume
List<TokenUnit> unitsToSpend = tokenUnitRepository.findActiveUnits(userId, amountToSpend);
if (unitsToSpend.size() < amountToSpend) {
throw new RuntimeException("SERIALIZED_RECONCILIATION_ERROR: Not enough active units found");
}
// PERMANENT REMOVAL: Physical delete from database
List<Long> unitIds = unitsToSpend.stream().map(TokenUnit::getId).collect(Collectors.toList());
String deleteSql = "DELETE FROM token_units WHERE id IN (" +
unitIds.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")";
jdbcTemplate.update(deleteSql);
// Update cached user balance
user.setRitzTokenBalance(currentBalance.subtract(amount));
userRepository.save(user);
@@ -63,9 +141,63 @@ public class TokenService {
user,
amount,
TokenTransaction.TransactionType.SPEND,
"Food Order Payment",
"Regulated Food Payment (Burned " + amountToSpend + " units)",
orderRef
);
transactionRepository.save(transaction);
System.out.println("BURNED: " + amountToSpend + " Ritz tokens for user " + userId + " [Order: " + orderRef + "]");
}
@Transactional
public void refund(Long userId, String orderRef, BigDecimal amount, String reason) {
// Lock user for safety
User user = userRepository.findByIdWithLock(userId)
.orElseThrow(() -> new RuntimeException("User not found for refund"));
// Since original tokens are deleted, we RESTORE balance by MINTING fresh tokens
int tokenCount = amount.intValue();
System.out.println("REFUNDING: Reminting " + tokenCount + " new Ritz tokens for user " + userId + " [" + reason + "]");
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
List<Object[]> batchArgs = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < tokenCount; i++) {
batchArgs.add(new Object[]{generateSecureHash(), userId, now});
}
jdbcTemplate.batchUpdate(sql, batchArgs);
// Update user balance
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
user.setRitzTokenBalance(currentBalance.add(amount));
userRepository.save(user);
// Log transaction
TokenTransaction transaction = new TokenTransaction(
user,
amount,
TokenTransaction.TransactionType.TOPUP,
"Order Refund: " + reason + " (Ref: " + orderRef + ")",
"REF-" + orderRef
);
transactionRepository.save(transaction);
}
private String generateSecureHash() {
try {
String base = UUID.randomUUID().toString() + System.nanoTime();
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(base.getBytes("UTF-8"));
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return "RTX-" + hexString.toString().substring(0, 32).toUpperCase();
} catch (Exception e) {
return "RTX-" + UUID.randomUUID().toString().toUpperCase();
}
}
}