This commit is contained in:
@@ -50,6 +50,7 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
|
|||||||
value.put("paymentEnabled", true);
|
value.put("paymentEnabled", true);
|
||||||
value.put("payoutEnabled", true);
|
value.put("payoutEnabled", true);
|
||||||
value.put("banned", false);
|
value.put("banned", false);
|
||||||
|
value.put("depositCount", 0);
|
||||||
|
|
||||||
Example example = new Example();
|
Example example = new Example();
|
||||||
example.setSummary("Current user response");
|
example.setSummary("Current user response");
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class UserController {
|
|||||||
var userBOpt = userBRepository.findById(user.getId());
|
var userBOpt = userBRepository.findById(user.getId());
|
||||||
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
|
Long balanceA = userBOpt.map(UserB::getBalanceA).orElse(0L);
|
||||||
Long balanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
|
Long balanceB = userBOpt.map(UserB::getBalanceB).orElse(0L);
|
||||||
|
Integer depositCount = userBOpt.map(UserB::getDepositCount).orElse(0);
|
||||||
|
|
||||||
// Generate avatar URL on-the-fly (deterministic from userId)
|
// Generate avatar URL on-the-fly (deterministic from userId)
|
||||||
String avatarUrl = avatarService.getAvatarUrl(user.getId());
|
String avatarUrl = avatarService.getAvatarUrl(user.getId());
|
||||||
@@ -61,6 +62,7 @@ public class UserController {
|
|||||||
.paymentEnabled(featureSwitchService.isPaymentEnabled())
|
.paymentEnabled(featureSwitchService.isPaymentEnabled())
|
||||||
.payoutEnabled(featureSwitchService.isPayoutEnabled())
|
.payoutEnabled(featureSwitchService.isPayoutEnabled())
|
||||||
.banned(banned)
|
.banned(banned)
|
||||||
|
.depositCount(depositCount)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class PaymentInvoiceResponse {
|
|||||||
private String invoiceUrl; // Invoice URL to open in Telegram (null for crypto)
|
private String invoiceUrl; // Invoice URL to open in Telegram (null for crypto)
|
||||||
private Integer starsAmount; // Amount in Stars (legacy)
|
private Integer starsAmount; // Amount in Stars (legacy)
|
||||||
private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto)
|
private Double usdAmount; // USD as decimal, e.g. 3.25 (crypto)
|
||||||
private Long ticketsAmount; // Tickets amount in bigint format
|
private Long ticketsAmount; // Legacy; for crypto use playBalance
|
||||||
|
private Long playBalance; // Play balance in bigint (1 USD = 10_000_000_000); for crypto deposits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,6 @@ public class UserDto {
|
|||||||
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed
|
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed
|
||||||
private Boolean payoutEnabled; // Runtime toggle: withdrawals (Payout, crypto withdrawal) allowed
|
private Boolean payoutEnabled; // Runtime toggle: withdrawals (Payout, crypto withdrawal) allowed
|
||||||
private Boolean banned; // Whether the user is banned
|
private Boolean banned; // Whether the user is banned
|
||||||
|
private Integer depositCount; // Number of deposits (from db_users_b); 0 = first deposit eligible for bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ public class Payment {
|
|||||||
private BigDecimal usdAmount; // stored as decimal, e.g. 1.25 USD = 1.25
|
private BigDecimal usdAmount; // stored as decimal, e.g. 1.25 USD = 1.25
|
||||||
|
|
||||||
@Column(name = "tickets_amount", nullable = false)
|
@Column(name = "tickets_amount", nullable = false)
|
||||||
private Long ticketsAmount; // Tickets amount in bigint format
|
private Long ticketsAmount; // Tickets amount in bigint format (legacy Stars)
|
||||||
|
|
||||||
|
@Column(name = "play_balance")
|
||||||
|
private Long playBalance; // Play balance in bigint (1 USD = 10_000_000_000); used for crypto deposits
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")
|
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")
|
||||||
|
|||||||
@@ -57,16 +57,23 @@ public class PaymentService {
|
|||||||
// Conversion rate: 1 Star = 9 Tickets, so in bigint: 1 Star = 9,000,000 (legacy)
|
// Conversion rate: 1 Star = 9 Tickets, so in bigint: 1 Star = 9,000,000 (legacy)
|
||||||
private static final long STARS_TO_TICKETS_MULTIPLIER = 9_000_000L;
|
private static final long STARS_TO_TICKETS_MULTIPLIER = 9_000_000L;
|
||||||
|
|
||||||
// USD stored as decimal (e.g. 1.25). Tickets in DB: 1 ticket = 1_000_000; 1 USD = 1000 tickets -> ticketsAmount = round(usdAmount * 1_000_000_000)
|
// USD stored as decimal (e.g. 1.25). Tickets in DB: 1 ticket = 1_000_000; 1 USD = 1000 tickets -> ticketsAmount = round(usdAmount * 1_000_000_000) (legacy Stars)
|
||||||
private static final long TICKETS_DB_UNITS_PER_USD = 1_000_000_000L;
|
private static final long TICKETS_DB_UNITS_PER_USD = 1_000_000_000L;
|
||||||
|
|
||||||
private static final double MIN_USD = 0.01;
|
// Honey app: game balance (in-game currency). 1 USD = 10_000 display units -> 1 USD = 10_000_000_000 in DB.
|
||||||
private static final double MAX_USD = 10_000.0;
|
private static final long GAME_BALANCE_DB_UNITS_PER_USD = 10_000_000_000L;
|
||||||
|
private static final double FIRST_DEPOSIT_BONUS_MULTIPLIER = 1.2; // 20% bonus on first deposit
|
||||||
|
|
||||||
|
private static final double MIN_USD = 3.0;
|
||||||
|
private static final double MAX_USD = 20_000.0;
|
||||||
|
|
||||||
// Minimum and maximum stars amounts (legacy)
|
// Minimum and maximum stars amounts (legacy)
|
||||||
private static final int MIN_STARS = 50;
|
private static final int MIN_STARS = 50;
|
||||||
private static final int MAX_STARS = 100000;
|
private static final int MAX_STARS = 100000;
|
||||||
|
|
||||||
|
/** When false, Telegram Stars payment webhooks are ignored (no balance credited). Invoice creation for Stars is already rejected. */
|
||||||
|
private static final boolean TELEGRAM_STARS_PAYMENTS_ENABLED = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a payment invoice for the user.
|
* Creates a payment invoice for the user.
|
||||||
* Validates the stars amount and creates a pending payment record.
|
* Validates the stars amount and creates a pending payment record.
|
||||||
@@ -83,7 +90,7 @@ public class PaymentService {
|
|||||||
if (usdAmountDouble != null) {
|
if (usdAmountDouble != null) {
|
||||||
validateUsd(usdAmountDouble);
|
validateUsd(usdAmountDouble);
|
||||||
BigDecimal usdAmount = BigDecimal.valueOf(usdAmountDouble).setScale(2, RoundingMode.UNNECESSARY);
|
BigDecimal usdAmount = BigDecimal.valueOf(usdAmountDouble).setScale(2, RoundingMode.UNNECESSARY);
|
||||||
long ticketsAmount = usdToTicketsAmount(usdAmountDouble);
|
long playBalance = usdToPlayBalance(usdAmountDouble);
|
||||||
|
|
||||||
String orderId = generateOrderId(userId);
|
String orderId = generateOrderId(userId);
|
||||||
Payment payment = Payment.builder()
|
Payment payment = Payment.builder()
|
||||||
@@ -91,18 +98,20 @@ public class PaymentService {
|
|||||||
.orderId(orderId)
|
.orderId(orderId)
|
||||||
.starsAmount(0)
|
.starsAmount(0)
|
||||||
.usdAmount(usdAmount)
|
.usdAmount(usdAmount)
|
||||||
.ticketsAmount(ticketsAmount)
|
.ticketsAmount(0L)
|
||||||
|
.playBalance(playBalance)
|
||||||
.status(Payment.PaymentStatus.PENDING)
|
.status(Payment.PaymentStatus.PENDING)
|
||||||
.build();
|
.build();
|
||||||
paymentRepository.save(payment);
|
paymentRepository.save(payment);
|
||||||
log.info("Payment invoice created (crypto): orderId={}, userId={}, usdAmount={}, ticketsAmount={}", orderId, userId, usdAmount, ticketsAmount);
|
log.info("Payment invoice created (crypto): orderId={}, userId={}, usdAmount={}, playBalance={}", orderId, userId, usdAmount, playBalance);
|
||||||
|
|
||||||
return PaymentInvoiceResponse.builder()
|
return PaymentInvoiceResponse.builder()
|
||||||
.invoiceId(orderId)
|
.invoiceId(orderId)
|
||||||
.invoiceUrl(null)
|
.invoiceUrl(null)
|
||||||
.starsAmount(null)
|
.starsAmount(null)
|
||||||
.usdAmount(usdAmountDouble)
|
.usdAmount(usdAmountDouble)
|
||||||
.ticketsAmount(ticketsAmount)
|
.ticketsAmount(null)
|
||||||
|
.playBalance(playBalance)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +122,12 @@ public class PaymentService {
|
|||||||
/**
|
/**
|
||||||
* Creates an invoice link via Telegram Bot API for Stars payment.
|
* Creates an invoice link via Telegram Bot API for Stars payment.
|
||||||
* The invoice link can be used with tg.openInvoice() in the Mini App.
|
* The invoice link can be used with tg.openInvoice() in the Mini App.
|
||||||
|
* Disabled when {@link #TELEGRAM_STARS_PAYMENTS_ENABLED} is false.
|
||||||
*/
|
*/
|
||||||
private String createInvoiceLink(String orderId, Integer starsAmount) {
|
private String createInvoiceLink(String orderId, Integer starsAmount) {
|
||||||
|
if (!TELEGRAM_STARS_PAYMENTS_ENABLED) {
|
||||||
|
throw new IllegalStateException(localizationService.getMessage("payment.error.legacyNotSupported"));
|
||||||
|
}
|
||||||
String botToken = telegramProperties.getBotToken();
|
String botToken = telegramProperties.getBotToken();
|
||||||
if (botToken == null || botToken.isEmpty()) {
|
if (botToken == null || botToken.isEmpty()) {
|
||||||
log.error("Bot token is not configured");
|
log.error("Bot token is not configured");
|
||||||
@@ -172,6 +185,11 @@ public class PaymentService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public boolean processPaymentWebhook(PaymentWebhookRequest request) {
|
public boolean processPaymentWebhook(PaymentWebhookRequest request) {
|
||||||
|
if (!TELEGRAM_STARS_PAYMENTS_ENABLED) {
|
||||||
|
log.warn("Telegram Stars payment webhook ignored (Stars payments disabled): orderId={}", request.getOrderId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
String orderId = request.getOrderId();
|
String orderId = request.getOrderId();
|
||||||
Long telegramUserId = request.getTelegramUserId();
|
Long telegramUserId = request.getTelegramUserId();
|
||||||
Integer starsAmount = request.getStarsAmount();
|
Integer starsAmount = request.getStarsAmount();
|
||||||
@@ -261,7 +279,7 @@ public class PaymentService {
|
|||||||
throw new IllegalArgumentException(localizationService.getMessage("payment.error.invalidPid"));
|
throw new IllegalArgumentException(localizationService.getMessage("payment.error.invalidPid"));
|
||||||
}
|
}
|
||||||
if (usdAmount == null) {
|
if (usdAmount == null) {
|
||||||
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000"));
|
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "3", "20000"));
|
||||||
}
|
}
|
||||||
validateUsd(usdAmount);
|
validateUsd(usdAmount);
|
||||||
|
|
||||||
@@ -334,8 +352,8 @@ public class PaymentService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a deposit completion from 3rd party webhook (e.g. crypto payment provider).
|
* Processes a deposit completion from 3rd party webhook (e.g. crypto payment provider).
|
||||||
* Creates a COMPLETED payment record, credits user balance, updates deposit stats, creates DEPOSIT transaction.
|
* Creates a COMPLETED payment with playBalance, credits balanceB, updates deposit stats, creates DEPOSIT transaction.
|
||||||
* Same effect as Telegram Stars webhook completion but for USD amount.
|
* First deposit gets 20% bonus (playBalance = usd * 12_000_000_000).
|
||||||
*
|
*
|
||||||
* @param userId internal user id (db_users_a.id)
|
* @param userId internal user id (db_users_a.id)
|
||||||
* @param usdAmount USD as decimal, e.g. 1.45 (3rd party sends as number)
|
* @param usdAmount USD as decimal, e.g. 1.45 (3rd party sends as number)
|
||||||
@@ -346,7 +364,7 @@ public class PaymentService {
|
|||||||
throw new IllegalArgumentException("user_id is required");
|
throw new IllegalArgumentException("user_id is required");
|
||||||
}
|
}
|
||||||
if (usdAmount == null) {
|
if (usdAmount == null) {
|
||||||
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000"));
|
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "3", "20000"));
|
||||||
}
|
}
|
||||||
validateUsd(usdAmount);
|
validateUsd(usdAmount);
|
||||||
|
|
||||||
@@ -358,7 +376,12 @@ public class PaymentService {
|
|||||||
UserB userB = userBRepository.findById(userId)
|
UserB userB = userBRepository.findById(userId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("user.error.balanceNotFound")));
|
.orElseThrow(() -> new IllegalArgumentException(localizationService.getMessage("user.error.balanceNotFound")));
|
||||||
|
|
||||||
long ticketsAmount = usdToTicketsAmount(usdAmount);
|
boolean firstDeposit = (userB.getDepositCount() == null || userB.getDepositCount() == 0);
|
||||||
|
long basePlayBalance = usdToPlayBalance(usdAmount); // USD * 10_000_000_000 (no bonus)
|
||||||
|
long playBalance = firstDeposit
|
||||||
|
? Math.round(usdAmount * GAME_BALANCE_DB_UNITS_PER_USD * FIRST_DEPOSIT_BONUS_MULTIPLIER)
|
||||||
|
: basePlayBalance;
|
||||||
|
|
||||||
BigDecimal usdAmountBd = BigDecimal.valueOf(usdAmount).setScale(2, RoundingMode.HALF_UP);
|
BigDecimal usdAmountBd = BigDecimal.valueOf(usdAmount).setScale(2, RoundingMode.HALF_UP);
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
|
|
||||||
@@ -367,30 +390,32 @@ public class PaymentService {
|
|||||||
.orderId(orderId)
|
.orderId(orderId)
|
||||||
.starsAmount(0)
|
.starsAmount(0)
|
||||||
.usdAmount(usdAmountBd)
|
.usdAmount(usdAmountBd)
|
||||||
.ticketsAmount(ticketsAmount)
|
.ticketsAmount(0L)
|
||||||
|
.playBalance(playBalance)
|
||||||
.status(Payment.PaymentStatus.COMPLETED)
|
.status(Payment.PaymentStatus.COMPLETED)
|
||||||
.completedAt(now)
|
.completedAt(now)
|
||||||
.build();
|
.build();
|
||||||
paymentRepository.save(payment);
|
paymentRepository.save(payment);
|
||||||
|
|
||||||
userB.setBalanceA(userB.getBalanceA() + ticketsAmount);
|
userB.setBalanceB(userB.getBalanceB() + playBalance);
|
||||||
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
|
// depositTotal = sum of base amounts only (no bonus), so we add basePlayBalance
|
||||||
|
userB.setDepositTotal(userB.getDepositTotal() + basePlayBalance);
|
||||||
userB.setDepositCount(userB.getDepositCount() + 1);
|
userB.setDepositCount(userB.getDepositCount() + 1);
|
||||||
userBRepository.save(userB);
|
userBRepository.save(userB);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
transactionService.createDepositTransaction(userId, ticketsAmount);
|
transactionService.createDepositTransaction(userId, playBalance);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error creating deposit transaction: userId={}, amount={}", userId, ticketsAmount, e);
|
log.error("Error creating deposit transaction: userId={}, amount={}", userId, playBalance, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("External deposit completed: orderId={}, userId={}, usdAmount={}, ticketsAmount={}", orderId, userId, usdAmountBd, ticketsAmount);
|
log.info("External deposit completed: orderId={}, userId={}, usdAmount={}, playBalance={}, firstDeposit={}", orderId, userId, usdAmountBd, playBalance, firstDeposit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** USD range 2–10000 and at most 2 decimal places. */
|
/** USD range 3–20000 and at most 2 decimal places. */
|
||||||
private void validateUsd(double usdAmount) {
|
private void validateUsd(double usdAmount) {
|
||||||
if (usdAmount < MIN_USD || usdAmount > MAX_USD) {
|
if (usdAmount < MIN_USD || usdAmount > MAX_USD) {
|
||||||
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "2", "10000"));
|
throw new IllegalArgumentException(localizationService.getMessage("payment.error.usdRange", "3", "20000"));
|
||||||
}
|
}
|
||||||
double rounded = Math.round(usdAmount * 100) / 100.0;
|
double rounded = Math.round(usdAmount * 100) / 100.0;
|
||||||
if (Math.abs(usdAmount - rounded) > 1e-9) {
|
if (Math.abs(usdAmount - rounded) > 1e-9) {
|
||||||
@@ -398,11 +423,16 @@ public class PaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts USD (e.g. 1.45) to tickets amount in DB. 1 USD = 1000 tickets; 1 ticket = 1_000_000 in DB. */
|
/** Converts USD (e.g. 1.45) to tickets amount in DB. 1 USD = 1000 tickets; 1 ticket = 1_000_000 in DB. (Legacy Stars) */
|
||||||
private long usdToTicketsAmount(double usdAmount) {
|
private long usdToTicketsAmount(double usdAmount) {
|
||||||
return Math.round(usdAmount * TICKETS_DB_UNITS_PER_USD);
|
return Math.round(usdAmount * TICKETS_DB_UNITS_PER_USD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts USD to play balance in DB. 1 USD = 10_000_000_000. */
|
||||||
|
private long usdToPlayBalance(double usdAmount) {
|
||||||
|
return Math.round(usdAmount * GAME_BALANCE_DB_UNITS_PER_USD);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks a payment as cancelled (e.g., user cancelled in Telegram UI).
|
* Marks a payment as cancelled (e.g., user cancelled in Telegram UI).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Add play_balance for Honey app: in-game currency (1 USD = 10_000_000_000 in DB).
|
||||||
|
-- New crypto deposits use play_balance and credit balance_b; legacy tickets_amount remains for Stars.
|
||||||
|
ALTER TABLE payments
|
||||||
|
ADD COLUMN play_balance BIGINT UNSIGNED NULL COMMENT 'Play balance in bigint (1 USD = 10_000_000_000); used for crypto deposits' AFTER tickets_amount;
|
||||||
Reference in New Issue
Block a user