From 65d4de46a6506ee2f9334b0231365b95428e99e1 Mon Sep 17 00:00:00 2001 From: Tihon Date: Wed, 11 Mar 2026 13:58:36 +0200 Subject: [PATCH] Store screen --- .../config/OpenApiExamplesCustomizer.java | 1 + .../honey/controller/UserController.java | 2 + .../honey/dto/PaymentInvoiceResponse.java | 3 +- .../java/com/honey/honey/dto/UserDto.java | 1 + .../java/com/honey/honey/model/Payment.java | 5 +- .../honey/honey/service/PaymentService.java | 72 +++++++++++++------ .../V71__payments_add_play_balance.sql | 4 ++ 7 files changed, 65 insertions(+), 23 deletions(-) create mode 100644 src/main/resources/db/migration/V71__payments_add_play_balance.sql diff --git a/src/main/java/com/honey/honey/config/OpenApiExamplesCustomizer.java b/src/main/java/com/honey/honey/config/OpenApiExamplesCustomizer.java index f354dad..8047721 100644 --- a/src/main/java/com/honey/honey/config/OpenApiExamplesCustomizer.java +++ b/src/main/java/com/honey/honey/config/OpenApiExamplesCustomizer.java @@ -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"); diff --git a/src/main/java/com/honey/honey/controller/UserController.java b/src/main/java/com/honey/honey/controller/UserController.java index 03b26f5..7e9161a 100644 --- a/src/main/java/com/honey/honey/controller/UserController.java +++ b/src/main/java/com/honey/honey/controller/UserController.java @@ -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(); } diff --git a/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java b/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java index 83ade87..e39aa0f 100644 --- a/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java +++ b/src/main/java/com/honey/honey/dto/PaymentInvoiceResponse.java @@ -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 } diff --git a/src/main/java/com/honey/honey/dto/UserDto.java b/src/main/java/com/honey/honey/dto/UserDto.java index 2a8266f..becca3b 100644 --- a/src/main/java/com/honey/honey/dto/UserDto.java +++ b/src/main/java/com/honey/honey/dto/UserDto.java @@ -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 } diff --git a/src/main/java/com/honey/honey/model/Payment.java b/src/main/java/com/honey/honey/model/Payment.java index 9305ba1..338740a 100644 --- a/src/main/java/com/honey/honey/model/Payment.java +++ b/src/main/java/com/honey/honey/model/Payment.java @@ -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)") diff --git a/src/main/java/com/honey/honey/service/PaymentService.java b/src/main/java/com/honey/honey/service/PaymentService.java index a646c52..b5164b8 100644 --- a/src/main/java/com/honey/honey/service/PaymentService.java +++ b/src/main/java/com/honey/honey/service/PaymentService.java @@ -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 2–10000 and at most 2 decimal places. */ + /** USD range 3–20000 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). * diff --git a/src/main/resources/db/migration/V71__payments_add_play_balance.sql b/src/main/resources/db/migration/V71__payments_add_play_balance.sql new file mode 100644 index 0000000..6e60496 --- /dev/null +++ b/src/main/resources/db/migration/V71__payments_add_play_balance.sql @@ -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;