Store screen
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m20s

This commit is contained in:
Tihon
2026-03-11 13:58:36 +02:00
parent 3ae3e88e44
commit 65d4de46a6
7 changed files with 65 additions and 23 deletions

View File

@@ -50,6 +50,7 @@ public class OpenApiExamplesCustomizer implements GlobalOpenApiCustomizer {
value.put("paymentEnabled", true);
value.put("payoutEnabled", true);
value.put("banned", false);
value.put("depositCount", 0);
Example example = new Example();
example.setSummary("Current user response");

View File

@@ -41,6 +41,7 @@ public class UserController {
var userBOpt = userBRepository.findById(user.getId());
Long balanceA = userBOpt.map(UserB::getBalanceA).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)
String avatarUrl = avatarService.getAvatarUrl(user.getId());
@@ -61,6 +62,7 @@ public class UserController {
.paymentEnabled(featureSwitchService.isPaymentEnabled())
.payoutEnabled(featureSwitchService.isPayoutEnabled())
.banned(banned)
.depositCount(depositCount)
.build();
}

View File

@@ -14,6 +14,7 @@ public class PaymentInvoiceResponse {
private String invoiceUrl; // Invoice URL to open in Telegram (null for crypto)
private Integer starsAmount; // Amount in Stars (legacy)
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
}

View File

@@ -23,5 +23,6 @@ public class UserDto {
private Boolean paymentEnabled; // Runtime toggle: deposits (Store, Payment Options, etc.) allowed
private Boolean payoutEnabled; // Runtime toggle: withdrawals (Payout, crypto withdrawal) allowed
private Boolean banned; // Whether the user is banned
private Integer depositCount; // Number of deposits (from db_users_b); 0 = first deposit eligible for bonus
}

View File

@@ -33,7 +33,10 @@ public class Payment {
private BigDecimal usdAmount; // stored as decimal, e.g. 1.25 USD = 1.25
@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)
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")

View File

@@ -57,16 +57,23 @@ public class PaymentService {
// 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;
// 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 double MIN_USD = 0.01;
private static final double MAX_USD = 10_000.0;
// Honey app: game balance (in-game currency). 1 USD = 10_000 display units -> 1 USD = 10_000_000_000 in DB.
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)
private static final int MIN_STARS = 50;
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.
* Validates the stars amount and creates a pending payment record.
@@ -83,7 +90,7 @@ public class PaymentService {
if (usdAmountDouble != null) {
validateUsd(usdAmountDouble);
BigDecimal usdAmount = BigDecimal.valueOf(usdAmountDouble).setScale(2, RoundingMode.UNNECESSARY);
long ticketsAmount = usdToTicketsAmount(usdAmountDouble);
long playBalance = usdToPlayBalance(usdAmountDouble);
String orderId = generateOrderId(userId);
Payment payment = Payment.builder()
@@ -91,18 +98,20 @@ public class PaymentService {
.orderId(orderId)
.starsAmount(0)
.usdAmount(usdAmount)
.ticketsAmount(ticketsAmount)
.ticketsAmount(0L)
.playBalance(playBalance)
.status(Payment.PaymentStatus.PENDING)
.build();
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()
.invoiceId(orderId)
.invoiceUrl(null)
.starsAmount(null)
.usdAmount(usdAmountDouble)
.ticketsAmount(ticketsAmount)
.ticketsAmount(null)
.playBalance(playBalance)
.build();
}
@@ -113,8 +122,12 @@ public class PaymentService {
/**
* Creates an invoice link via Telegram Bot API for Stars payment.
* 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) {
if (!TELEGRAM_STARS_PAYMENTS_ENABLED) {
throw new IllegalStateException(localizationService.getMessage("payment.error.legacyNotSupported"));
}
String botToken = telegramProperties.getBotToken();
if (botToken == null || botToken.isEmpty()) {
log.error("Bot token is not configured");
@@ -172,6 +185,11 @@ public class PaymentService {
*/
@Transactional
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();
Long telegramUserId = request.getTelegramUserId();
Integer starsAmount = request.getStarsAmount();
@@ -261,7 +279,7 @@ public class PaymentService {
throw new IllegalArgumentException(localizationService.getMessage("payment.error.invalidPid"));
}
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);
@@ -334,8 +352,8 @@ public class PaymentService {
/**
* 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.
* Same effect as Telegram Stars webhook completion but for USD amount.
* Creates a COMPLETED payment with playBalance, credits balanceB, updates deposit stats, creates DEPOSIT transaction.
* First deposit gets 20% bonus (playBalance = usd * 12_000_000_000).
*
* @param userId internal user id (db_users_a.id)
* @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");
}
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);
@@ -358,7 +376,12 @@ public class PaymentService {
UserB userB = userBRepository.findById(userId)
.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);
Instant now = Instant.now();
@@ -367,30 +390,32 @@ public class PaymentService {
.orderId(orderId)
.starsAmount(0)
.usdAmount(usdAmountBd)
.ticketsAmount(ticketsAmount)
.ticketsAmount(0L)
.playBalance(playBalance)
.status(Payment.PaymentStatus.COMPLETED)
.completedAt(now)
.build();
paymentRepository.save(payment);
userB.setBalanceA(userB.getBalanceA() + ticketsAmount);
userB.setDepositTotal(userB.getDepositTotal() + ticketsAmount);
userB.setBalanceB(userB.getBalanceB() + playBalance);
// depositTotal = sum of base amounts only (no bonus), so we add basePlayBalance
userB.setDepositTotal(userB.getDepositTotal() + basePlayBalance);
userB.setDepositCount(userB.getDepositCount() + 1);
userBRepository.save(userB);
try {
transactionService.createDepositTransaction(userId, ticketsAmount);
transactionService.createDepositTransaction(userId, playBalance);
} 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 210000 and at most 2 decimal places. */
/** USD range 320000 and at most 2 decimal places. */
private void validateUsd(double usdAmount) {
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;
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) {
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).
*

View File

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