Ritz token page added
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -8,4 +8,5 @@ import java.util.List;
|
||||
@Repository
|
||||
public interface TokenTransactionRepository extends JpaRepository<TokenTransaction, Long> {
|
||||
List<TokenTransaction> findByUserIdOrderByTimestampDesc(Long userId);
|
||||
List<TokenTransaction> findAllByOrderByTimestampDesc();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user