/*
* Copyright 2015 Evgeny Dolganov (evgenij.dolganov@gmail.com).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package och.front.service;
import static java.math.BigDecimal.*;
import static java.util.Collections.*;
import static och.api.model.BaseBean.*;
import static och.api.model.PropKey.*;
import static och.api.model.billing.PaymentBase.*;
import static och.api.model.billing.PaymentStatus.*;
import static och.api.model.billing.PaymentType.*;
import static och.api.model.user.SecurityContext.*;
import static och.api.model.web.ReqInfo.*;
import static och.comp.db.main.table.MainTables.*;
import static och.comp.ops.BillingOps.*;
import static och.util.Util.*;
import static och.util.sql.SingleTx.*;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import och.api.annotation.Secured;
import och.api.exception.ExpectedException;
import och.api.exception.billing.NoDataToConfirmPaymentException;
import och.api.exception.user.UserNotFoundException;
import och.api.model.billing.PayData;
import och.api.model.billing.PaymentBase;
import och.api.model.billing.PaymentExt;
import och.api.model.billing.PaymentType;
import och.api.model.billing.UserBalance;
import och.api.model.billing.UserStartBonus;
import och.api.model.user.User;
import och.comp.cache.client.CacheClient;
import och.comp.db.base.universal.SelectRows;
import och.comp.db.main.table.billing.CreatePayment;
import och.comp.db.main.table.billing.GetPaymentsByUserId;
import och.comp.db.main.table.billing.GetStartBonusByUserId;
import och.comp.db.main.table.billing.SelectUserBalanceById;
import och.comp.db.main.table.billing.SelectUserBalancesByIds;
import och.comp.db.main.table.billing.UpdateUserAccsBlocked;
import och.comp.db.main.table.billing.UpdateUserStartBonus;
import och.comp.mail.SendReq;
import och.comp.paypal.PaypalClient;
import och.comp.paypal.PaypalClientListener;
import och.comp.tocheckout.ToCheckoutProvider;
import och.front.service.event.DBInitedEvent;
import och.front.service.event.admin.UpdateModelsEvent;
import och.service.billing.BillingProvider;
import och.service.props.Props;
import och.util.sql.ConcurrentUpdateSqlException;
public class BillingService extends BaseFrontService {
HashMap<String, BillingProvider> providers = new HashMap<>();
PaypalClient paypalClient;
ToCheckoutProvider toCheckoutProvider;
ChatService chats;
CacheClient cache;
public BillingService(FrontAppContext c) {
super(c);
}
@Override
public void init() throws Exception {
paypalClient = c.paypalClient;
chats = c.root.chats;
cache = c.cache;
toCheckoutProvider = c.toCheckoutProvider;
Props props = c.props;
providers.put(props.getStrVal(paypal_key), paypalClient);
providers.put(props.getStrVal(toCheckout_key), c.toCheckoutProvider);
paypalClient.addListener(new PaypalClientListener() {
@Override
public void onPaymentWarning(String respData) {
c.mails.sendAsyncWarnData("Warnings in payments", respData);
}
});
c.events.addListener(DBInitedEvent.class, (event)-> loadBalancesToCache());
c.events.addListener(UpdateModelsEvent.class, (event)-> updateAllUserBalanceCaches());
}
@Secured
public BigDecimal getCurUserBalance() throws Exception {
long userId = findUserIdFromSecurityContext();
pushToSecurityContext_SYSTEM_USER();
try {
return getUserBalance(userId);
}finally {
popUserFromSecurityContext();
}
}
@Secured
public BigDecimal getUserBalance(long userId) throws UserNotFoundException, Exception {
checkAccessFor_MODERATOR();
String cacheKey = getBalanceCacheKey(userId);
BigDecimal val = tryParseBigDecimal(cache.tryGetVal(cacheKey), null) ;
if(val == null){
UserBalance data = universal.selectOne(new SelectUserBalanceById(userId));
if(data == null) throw new UserNotFoundException(userId);
val = data.balance == null? ZERO : data.balance;
cache.putCacheAsync(cacheKey, val.toString());
}
return val;
}
@Secured
public void updateUserBalanceCache(Long id, boolean async){
updateUserBalanceCache(singleton(id), async);
}
@Secured
public void updateUserBalanceCache(Set<Long> ids, boolean async){
checkAccessFor_ADMIN();
try {
SelectRows<UserBalance> select = ids.size() == 1? new SelectUserBalanceById(firstFrom(ids)) : new SelectUserBalancesByIds(ids);
List<UserBalance> infos = universal.select(select);
for (UserBalance info : infos) {
String key = getBalanceCacheKey(info.userId);
String val = info.balance.toString();
if(async) cache.putCacheAsync(key, val);
else cache.tryPutCache(key, val);
}
}catch(Throwable t){
ExpectedException.logError(log, t, "can't updateUserBalanceCaches");
}
}
private void updateAllUserBalanceCaches(){
pushToSecurityContext_SYSTEM_USER();
try {
Map<String, Long> allOwners = chats.getAllAccOwners();
HashSet<Long> ids = new HashSet<Long>(allOwners.values());
updateUserBalanceCache(ids, false);
} finally {
popUserFromSecurityContext();
}
}
@Secured
public PayData sendPayReq(String providerKey, BigDecimal val) throws Exception{
User user = findUserFromSecurityContext();
long userId = user.id;
BillingProvider provider = getProvider(providerKey);
validateState(val.doubleValue() > 0, "val");
PayData payData = provider.payWithAccReq(val, userId);
if(payData.token == null)
payData.token = randomUUID();
String cacheData = toJson(new PayReqCacheData(payData.token, val));
cache.putCache(getPayReqCacheKey(userId), cacheData , props.getIntVal(payment_payReqTokenLivetime));
log.info("pay req sended: userId="+userId
+", login="+user.login
+", req="+getReqInfoStr());
return payData;
}
@Secured
public BigDecimal paypal_preparePayConfirm(String userPayId, String token) throws NoDataToConfirmPaymentException, IOException {
User user = findUserFromSecurityContext();
long userId = user.id;
PayReqCacheData reqData = checkAndGetPayReq(token);
String confirmData = toJson(new PayConfirmCacheData(reqData.token, reqData.val, userPayId));
cache.putCache(getConfirmPayCacheKey(userId), confirmData, props.getIntVal(payment_payConfirmDataLivetime));
log.info("pay confirm prepared: userId="+userId
+", login="+user.login
+", req="+getReqInfoStr());
return reqData.val;
}
@Secured
public BigDecimal paypal_getPayConfirmVal(){
long userId = findUserIdFromSecurityContext();
PayConfirmCacheData data = tryParseJson(cache.tryGetVal(getConfirmPayCacheKey(userId)), PayConfirmCacheData.class);
return data == null? BigDecimal.ZERO : data.val;
}
@Secured
public void cancelPayment(){
User user = findUserFromSecurityContext();
long userId = user.id;
//clear caches
cache.removeCacheAsync(getPayReqCacheKey(userId));
cache.removeCacheAsync(getConfirmPayCacheKey(userId));
log.info("pay canceled: userId="+userId
+", login="+user.login
+", req="+getReqInfoStr());
}
@Secured
public void paypal_finishPayment() throws NoDataToConfirmPaymentException, Exception {
findUserFromSecurityContext();
paypal_finishPayment(null);
}
@Secured
public void paypal_finishPayment(Date nowPreset) throws NoDataToConfirmPaymentException, Exception {
User user = findUserFromSecurityContext();
long userId = user.id;
PayConfirmCacheData data = tryParseJson(cache.tryGetVal(getConfirmPayCacheKey(userId)), PayConfirmCacheData.class);
if( data == null) throw new NoDataToConfirmPaymentException();
//call paypal
PaymentBase result = paypalClient.finishPayment(data.token, data.userPayId, data.val, userId);
pushToSecurityContext_SYSTEM_USER();
try {
addPay_Full(user, result, nowPreset);
} finally {
popUserFromSecurityContext();
}
}
@Secured
public void tochekout_finishPayment(String token, String txId) throws NoDataToConfirmPaymentException, Exception {
tochekout_finishPayment(token, txId, null);
}
@Secured
public void tochekout_finishPayment(String token, String txId, Date nowPreset) throws NoDataToConfirmPaymentException, Exception {
User user = findUserFromSecurityContext();
PayReqCacheData payData = checkAndGetPayReq(token);
PaymentBase result = toCheckoutProvider.finishPayment(payData.val, user.id, txId);
pushToSecurityContext_SYSTEM_USER();
try {
addPay_Full(user, result, nowPreset);
} finally {
popUserFromSecurityContext();
}
}
/**
* Positive amount. Unblock account if need
*/
@Secured
public void addPay_Full(User user, PaymentBase payData, Date nowPreset) throws Exception{
checkAccessFor_ADMIN();
Date now = nowPreset == null? new Date() : nowPreset;
long userId = user.id;
//clear caches anyway
cache.removeCacheAsync(getPayReqCacheKey(userId));
cache.removeCacheAsync(getConfirmPayCacheKey(userId));
PaymentExt payment = new PaymentExt(payData);
payment.id = universal.nextSeqFor(payments);
payment.userId = userId;
payment.payType = PaymentType.REPLENISHMENT;
payment.updated = now;
BigDecimal minActiveBalance = props.getBigDecimalVal(billing_minActiveBalance);
//update db
BigDecimal[] updatedBalance = {null};
boolean[] unblocked = {false};
doInSingleTxMode(()->{
universal.update(new CreatePayment(payment));
if(payment.paymentStatus == COMPLETED){
BigDecimal curBalance = findBalance(universal, userId).balance;
updatedBalance[0] = appendBalance(universal, userId, payment.amount);
if(isNeedDeblockAccsState(curBalance, updatedBalance[0], minActiveBalance)) {
unblocked[0] = true;
universal.update(new UpdateUserAccsBlocked(userId, false));
db.chats.updateOwnersAccsLastPay(userId, now);
}
}
});
//update balance cache if need
if(updatedBalance[0] != null){
cache.tryPutCache(getBalanceCacheKey(userId), updatedBalance[0].toString());
}
//update chat servers
if(unblocked[0]){
sendAccsBlocked(props, db, cache, userId, false);
}
sendPaymentConfimedEmailAsync(user.email);
log.info("pay finished: userId="+userId
+", login="+user.login
+(updatedBalance[0] != null? ", newBalance="+updatedBalance[0] : "")
+", req="+getReqInfoStr());
}
@Secured
public List<PaymentExt> getPayments(int limit, int offset) throws Exception{
long userId = findUserIdFromSecurityContext();
pushToSecurityContext_SYSTEM_USER();
try {
return getPayments(userId, limit, offset);
}finally {
popUserFromSecurityContext();
}
}
@Secured
public List<PaymentExt> getPayments(long userId, int limit, int offset) throws Exception{
checkAccessFor_ADMIN();
return universal.select(new GetPaymentsByUserId(userId, limit, offset));
}
@Secured
public void payBill(long userId, BigDecimal amount, Date created, PaymentType payType, String details) throws Exception{
checkAccessFor_ADMIN();
payBill(userId, amount, created, payType, details, true);
}
/**
* Negative amount
*/
@Secured
public void payBill(
long userId,
BigDecimal amount,
Date created,
PaymentType payType,
String details,
boolean updateCache) throws ConcurrentUpdateSqlException, Exception{
checkAccessFor_ADMIN();
long id = universal.nextSeqFor(payments);
PaymentExt payment = PaymentExt.createSystemBill(id, userId, amount, created, payType, details);
BigDecimal newVal = doPayment(userId, payment, updateCache);
log.info("bill payed: userId="+userId
+(newVal != null? ", newBalance="+newVal : "")
+", req="+getReqInfoStr());
}
/**
* Positive amount. No unblocks if blocked
*/
@Secured
public void addPay_Simple(
long userId,
BigDecimal amount,
Date created,
PaymentType payType,
String details,
boolean updateCache) throws ConcurrentUpdateSqlException, Exception{
checkAccessFor_ADMIN();
amount = amount.abs();
long id = universal.nextSeqFor(payments);
PaymentExt payment = PaymentExt.createSystemPayment(id, userId, amount, created, payType, details);
BigDecimal newVal = doPayment(userId, payment, updateCache);
log.info("pay added: userId="+userId
+(newVal != null? ", newBalance="+newVal : "")
+", req="+getReqInfoStr());
}
private BigDecimal doPayment(
long userId,
PaymentExt payment,
boolean updateCache) throws ConcurrentUpdateSqlException, Exception{
//update db
BigDecimal[] updatedBalance = {null};
doInSingleTxMode(()->{
universal.update(new CreatePayment(payment));
updatedBalance[0] = appendBalance(universal, userId, payment.amount);
});
if(updateCache){
cache.tryPutCache(getBalanceCacheKey(userId), updatedBalance[0].toString());
}
return updatedBalance[0];
}
public boolean addStartBonus(long userId) throws Exception{
pushToSecurityContext_SYSTEM_USER();
try {
UserStartBonus curVal = universal.selectOne(new GetStartBonusByUserId(userId));
if(isEmpty(curVal) || curVal.startBonusAdded) return false;
BigDecimal bonusVal = props.getBigDecimalVal(promo_startBonus);
if(ZERO.compareTo(bonusVal) >= 0) return false;
Boolean[] valid = {true};
doInSingleTxMode(()->{
Integer result = universal.updateOne(new UpdateUserStartBonus(userId, false, true));
if(result == 0) {
valid[0] = false;
return;
}
addPay_Simple(userId, bonusVal, new Date(), START_BONUS, null, false);
});
if( ! valid[0]) return false;
//all done - update balance cache
updateUserBalanceCache(userId, true);
return true;
}finally {
popUserFromSecurityContext();
}
}
private void loadBalancesToCache() throws Exception{
log.info("load balances to cache...");
pushToSecurityContext_SYSTEM_USER();
try {
Map<String, Long> accOwners = chats.getAllAccOwners();
Collection<Long> userIds = accOwners.values();
List<UserBalance> balances = universal.select(new SelectUserBalancesByIds(userIds));
for (UserBalance b : balances) {
cache.tryPutCache(getBalanceCacheKey(b.userId), b.balance.toString());
}
}finally {
popUserFromSecurityContext();
}
log.info("done");
}
private void sendPaymentConfimedEmailAsync(String email) {
try {
String subject = c.templates.fromTemplate("payment-confirmed-subject.ftl");
String html = c.templates.fromTemplate("payment-confirmed-text.ftl");
c.mails.sendAsync(new SendReq(email, subject, html));
} catch (Exception e) {
log.error("can't send email", e);
}
}
private BillingProvider getProvider(String providerKey) {
BillingProvider provider = providers.get(providerKey);
validateForEmpty(provider, "provider");
return provider;
}
private PayReqCacheData checkAndGetPayReq(String token){
User user = findUserFromSecurityContext();
long userId = user.id;
PayReqCacheData reqData = tryParseJson(cache.tryGetVal(getPayReqCacheKey(userId)), PayReqCacheData.class);
if( isEmpty(reqData)) throw new NoDataToConfirmPaymentException();
if( ! reqData.token.equals(token)) throw new NoDataToConfirmPaymentException();
return reqData;
}
static String getPayReqCacheKey(long userId){
return "pay-req-"+userId;
}
static String getConfirmPayCacheKey(long userId){
return "pay-confirm-"+userId;
}
public static class PayReqCacheData {
public String token;
public BigDecimal val;
public PayReqCacheData() {
}
public PayReqCacheData(String token, BigDecimal val) {
this.token = token;
this.val = val;
}
}
public static class PayConfirmCacheData {
public String token;
public BigDecimal val;
public String userPayId;
public PayConfirmCacheData() {
}
public PayConfirmCacheData(String token, BigDecimal val, String userPayId) {
this.token = token;
this.val = val;
this.userPayId = userPayId;
}
}
}