@@ -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 " , " 1 0000" ) ) ;
throw new IllegalArgumentException ( localizationService . getMessage ( " payment.error.usdRange " , " 3 " , " 2 0000" ) ) ;
}
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 " , " 1 0000" ) ) ;
throw new IllegalArgumentException ( localizationService . getMessage ( " payment.error.usdRange " , " 3 " , " 2 0000" ) ) ;
}
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={}, ticketsAmoun t={} " , orderId , userId , usdAmountBd , ticketsAmoun t) ;
log . info ( " External deposit completed: orderId={}, userId={}, usdAmount={}, playBalance={}, firstDeposi t={} " , orderId , userId , usdAmountBd , playBalance , firstDeposi t) ;
}
/** USD range 2– 1 0000 and at most 2 decimal places. */
/** USD range 3– 2 0000 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 " , " 1 0000" ) ) ;
throw new IllegalArgumentException ( localizationService . getMessage ( " payment.error.usdRange " , " 3 " , " 2 0000" ) ) ;
}
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).
*