/*
* 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.comp.billing.standalone;
import static java.math.BigDecimal.*;
import static java.util.Collections.*;
import static och.api.model.PropKey.*;
import static och.api.model.RemoteCache.*;
import static och.api.model.RemoteChats.*;
import static och.api.model.billing.PaymentBase.*;
import static och.api.model.billing.PaymentExt.*;
import static och.api.model.billing.PaymentType.*;
import static och.api.model.chat.account.PrivilegeType.*;
import static och.api.model.tariff.TariffMath.*;
import static och.comp.db.main.table.MainTables.*;
import static och.comp.ops.BillingOps.*;
import static och.comp.ops.ServersOps.*;
import static och.comp.web.JsonOps.*;
import static och.util.DateUtil.*;
import static och.util.ExceptionUtil.*;
import static och.util.FileUtil.*;
import static och.util.StringUtil.*;
import static och.util.Util.*;
import static och.util.model.HoursAndMinutes.*;
import static och.util.sql.SingleTx.*;
import java.io.File;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import och.api.exception.ExpectedException;
import och.api.model.billing.AdminSyncResp;
import och.api.model.billing.LastSyncInfo;
import och.api.model.billing.PaymentExt;
import och.api.model.billing.UserBalance;
import och.api.model.chat.account.ChatAccount;
import och.api.model.chat.account.ChatAccountPrivileges;
import och.api.model.server.ServerRow;
import och.api.model.tariff.Tariff;
import och.api.remote.chats.GetPausedStateReq;
import och.api.remote.chats.GetUnblockedAccsReq;
import och.api.remote.chats.ResultAccsResp;
import och.comp.cache.Cache;
import och.comp.cache.server.CacheServerContext;
import och.comp.cache.server.CacheServerContextHolder;
import och.comp.db.base.universal.UniversalQueries;
import och.comp.db.main.MainDb;
import och.comp.db.main.table._f.TariffLastPay;
import och.comp.db.main.table.billing.CreatePayment;
import och.comp.db.main.table.billing.GetAllBlockedUsers;
import och.comp.db.main.table.billing.UpdateUserAccsBlocked;
import och.comp.db.main.table.chat.GetAllChatAccounts;
import och.comp.db.main.table.chat.UpdateChatAccountByUid;
import och.comp.db.main.table.chat.privilege.GetAllChatAccountPrivileges;
import och.comp.db.main.table.tariff.GetAllTariffs;
import och.comp.mail.MailService;
import och.comp.ops.BillingOps.PausedStateResp;
import och.service.props.Props;
import och.util.model.CallableVoid;
import och.util.model.HasInitState;
import och.util.model.HoursAndMinutes;
import och.util.sql.ConcurrentUpdateSqlException;
import och.util.timer.TimerExt;
import org.apache.commons.logging.Log;
public class BillingSyncService implements HasInitState, CacheServerContextHolder {
private Log log = getLog(getClass());
private Props props;
private MainDb db;
private UniversalQueries universal;
private MailService mailService;
private Cache cache;
private TimerExt paySyncTimer;
private TimerExt cacheMonitorTimer;
private TimerExt blockSyncTimer;
public CallableVoid syncAccsListener;
@Override
public void setCacheServerContext(CacheServerContext c) {
this.props = c.props;
this.db = c.mainDb;
this.universal = db.universal;
this.mailService = c.mailService;
this.cache = c.cache;
}
@Override
public void init() throws Exception {
checkStateForEmpty(props, "props");
checkStateForEmpty(cache, "cache");
checkStateForEmpty(mailService, "mailService");
checkStateForEmpty(universal, "universal");
//block and pause sync
if(props.getBoolVal(billing_sync_fillBlockedCacheOnStart)){
long delay = props.getLongVal(billing_sync_fillBlockedCacheOnStartDelay);
if(delay < 1){
reinitAccsBlocked(props, db, cache);
reinitAccsPaused(props, db);
} else {
TimerExt timer = new TimerExt("BillingSyncService-fillBlockedCache", false);
timer.trySchedule(()->{
reinitAccsBlocked(props, db, cache);
reinitAccsPaused(props, db);
timer.cancel();
}, delay);
}
}
loadLastSyncInfo();
//skip timer
if( props.getBoolVal(billing_sync_debug_DisableTimer)) return;
paySyncTimer = new TimerExt("BillingSyncService-pay-sync", false);
paySyncTimer.tryScheduleAtFixedRate(()->
doSyncWork(true, null, null),
props.getLongVal(billing_sync_timerDelay),
props.getLongVal(billing_sync_timerDelta));
blockSyncTimer = new TimerExt("BillingSyncService-block-sync", false);
blockSyncTimer.tryScheduleAtFixedRate(()-> {
checkAccBlocks();
checkAccPaused();
},
props.getLongVal(billing_sync_blockCheckTimerDelay),
props.getLongVal(billing_sync_blockCheckTimerDelta));
cacheMonitorTimer = new TimerExt("BillingSyncService-tasks-checker", false);
cacheMonitorTimer.tryScheduleAtFixedRate(()->
checkTasksFromCache(),
props.getLongVal(billing_sync_taskMonitorTimerDelay),
props.getLongVal(billing_sync_taskMonitorTimerDelta));
}
private void loadLastSyncInfo() {
File file = new File(props.getStrVal(billing_sync_lastSyncFile));
String lastSyncInfo = file.exists()? tryReadFileUTF8(file) : null;
cache.tryPutCache(BILLING_LAST_SYNC, lastSyncInfo);
}
private void saveLastSyncInfo(int updated) {
if( ! props.getBoolVal(billing_sync_lastSyncStore)) return;
String lastSyncInfo = toJson(new LastSyncInfo(updated), true);
cache.tryPutCache(BILLING_LAST_SYNC, lastSyncInfo);
tryWriteFileUTF8(new File(props.getStrVal(billing_sync_lastSyncFile)), lastSyncInfo);
}
public void checkTasksFromCache() throws Exception{
String reqId = cache.tryRemoveCache(BILLING_SYNC_REQ);
if(reqId == null) return;
cache.tryPutCache(BILLING_SYNC_RESP, BILLING_SYNC_RESP_START_PREFIX+"started at "+new Date());
log.info("doSyncWork by admin req: "+reqId);
long start = System.currentTimeMillis();
int updatedCount = doSyncWork(false, null, null);
long worktime = System.currentTimeMillis() - start;
log.info("done. worktime: "+worktime+"ms");
AdminSyncResp result = new AdminSyncResp(reqId, updatedCount, worktime);
cache.tryPutCache(BILLING_SYNC_RESP, toJson(result, true));
}
public int doSyncWork(boolean checkWorkTime) throws Exception {
return doSyncWork(checkWorkTime, null, null);
}
public int doSyncWork(boolean checkWorkTime, Date nowPreset) throws Exception {
return doSyncWork(checkWorkTime, nowPreset, null);
}
public int doSyncWork(boolean checkWorkTime,
Date nowPreset,
CallableVoid beforeDbUpdateListener) throws Exception {
Date now = nowPreset != null? nowPreset : new Date();
if(props.getBoolVal(billing_sync_debug_DisableSync)) return -1;
if(props.getBoolVal(toolMode)) return -1;
//проверка актуальности старта
if(checkWorkTime && props.getBoolVal(billing_sync_debug_CheckWorkTime)){
int dayOfMonth = dayOfMonth(now);
int endDay = props.getIntVal(billing_sync_endSyncDay);
int startDay = props.getIntVal(billing_sync_startSyncDay);
if(dayOfMonth < startDay) return -2;
if(dayOfMonth > endDay) return -3;
HoursAndMinutes nowHHmm = getHoursAndMinutes(now);
if(dayOfMonth == startDay){
HoursAndMinutes startHHmm = tryParseHHmm(props.getStrVal(billing_sync_startSyncTime), null);
if(startHHmm != null && nowHHmm.compareTo(startHHmm) < 0) return -2;
}
if(dayOfMonth == endDay){
HoursAndMinutes endHHmm = tryParseHHmm(props.getStrVal(billing_sync_endSyncTime), null);
if(endHHmm != null && nowHHmm.compareTo(endHHmm) > 0) return -3;
}
}
Date curMonthStart = monthStart(now);
//get all accs
HashSet<Long> needPayAccs = new HashSet<Long>();
HashMap<Long, ChatAccount> accsById = new HashMap<>();
List<ChatAccount> allAccs = universal.select(new GetAllChatAccounts());
for (ChatAccount acc : allAccs) {
accsById.put(acc.id, acc);
if(isNeedToPay(acc, curMonthStart))
needPayAccs.add(acc.id);
}
if(props.getBoolVal(billing_sync_log)) log.info("sync accs to pay ("+needPayAccs.size()+"): "+needPayAccs);
if(isEmpty(needPayAccs)) {
saveLastSyncInfo(0);
return 0;
}
//get tariffs
List<Tariff> tariffs = universal.select(new GetAllTariffs());
HashMap<Long, Tariff> tariffsById = new HashMap<>();
for(Tariff t : tariffs) tariffsById.put(t.id, t);
//find owners
HashMap<Long, Set<ChatAccount>> accsByUser = new HashMap<>();
List<ChatAccountPrivileges> allUsersPrivs = universal.select(new GetAllChatAccountPrivileges());
for (ChatAccountPrivileges data : allUsersPrivs) {
if(data.privileges.contains(CHAT_OWNER)){
ChatAccount acc = accsById.get(data.accId);
if(acc == null) continue;
putToSetMap(accsByUser, data.userId, acc);
}
}
if(beforeDbUpdateListener != null) beforeDbUpdateListener.call();
//sync by owners
ArrayList<SyncPayError> syncErrors = new ArrayList<>();
for (Entry<Long, Set<ChatAccount>> entry : accsByUser.entrySet()) {
Long userId = entry.getKey();
Set<ChatAccount> userAccs = entry.getValue();
try {
if(syncAccsListener != null) syncAccsListener.call();
List<SyncPayError> curErrors = syncUserAccs(userId, userAccs, tariffsById, curMonthStart, now);
if(curErrors.size() > 0) syncErrors.addAll(curErrors);
}
//произошла смена тарифа, до того как успели посчитать
//новая попытка расчета будет на следующем вызове таймера
catch(ConcurrentUpdateSqlException e){
//удаляем из выходного результата акки
for(ChatAccount acc : userAccs) needPayAccs.remove(acc.id);
}
catch(Throwable t){
log.error("can't sync accs for user="+userId+": "+t);
syncErrors.add(new SyncPayError(userId, userAccs, t));
}
}
if(syncErrors.size() > 0)
sendSyncErrorMailToAdmin("Sync billing errors", syncErrors);
int updated = needPayAccs.size();
saveLastSyncInfo(updated);
return updated;
}
private List<SyncPayError> syncUserAccs(
long userId,
Set<ChatAccount> userAccs,
Map<Long, Tariff> tariffs,
Date curMonthStart,
Date now) throws ConcurrentUpdateSqlException, Exception {
List<SyncPayError> errors = new ArrayList<>();
BigDecimal totalPrice = ZERO;
Date lastMonthStart = addMonths(curMonthStart, -1);
Date lastMonthEnd = monthEnd(lastMonthStart);
ArrayList<ChatAccount> accsToUpdate = new ArrayList<>();
for(ChatAccount acc : userAccs){
if(isNeedToPay(acc, curMonthStart)) {
Tariff tariff = tariffs.get(acc.tariffId);
if(tariff == null) {
errors.add(new SyncPayError(userId, singleton(acc), new IllegalStateException("can't find tariff")));
continue;
}
accsToUpdate.add(acc);
BigDecimal price = tariff.price;
if(price.compareTo(ZERO) < 1) continue;
//если целый месяц - полный тариф, иначе расчет по периоду
BigDecimal accAmount;
Date oldPayDate = acc.tariffLastPay;
if(oldPayDate.compareTo(lastMonthStart) == 0) accAmount = price;
else accAmount = calcForPeriod(price, oldPayDate, lastMonthEnd, ZERO);
totalPrice = totalPrice.add(accAmount);
}
}
//final amount
BigDecimal amount = totalPrice;
boolean hasBill = ZERO.compareTo(amount) != 0;
BigDecimal[] updatedBalance = {null};
BigDecimal minActiveBalance = props.getBigDecimalVal(billing_minActiveBalance);
boolean[] accBlocked = {false};
//update db
doInSingleTxMode(()->{
boolean isBlocked = findBalance(universal, userId).accsBlocked;
//bill
if( ! isBlocked && hasBill) {
long payId = universal.nextSeqFor(payments);
PaymentExt payment = createSystemBill(payId, userId, amount, now, TARIFF_MONTH_BIll, collectionToStr(accsToUpdate));
universal.update(new CreatePayment(payment));
updatedBalance[0] = appendBalance(universal, userId, payment.amount);
//если баланс стал отрицательным и то блокируем акки
if(updatedBalance[0].compareTo(minActiveBalance) < 0){
accBlocked[0] = true;
universal.update(new UpdateUserAccsBlocked(userId, true));
}
}
//update last pay dates
if(accsToUpdate.size() > 0) {
for (ChatAccount acc : accsToUpdate) {
int result = universal.updateOne(new UpdateChatAccountByUid(
acc.uid,
acc.tariffLastPay,
new TariffLastPay(curMonthStart)));
//concurrent check
if(result == 0) throw new ConcurrentUpdateSqlException("UpdateChatAccountByUid: uid="+acc.uid);
}
}
});
//update cache
if(updatedBalance[0] != null) {
cache.tryPutCache(getBalanceCacheKey(userId), updatedBalance[0].toString());
}
//update chat servers
if(accBlocked[0]){
sendAccsBlocked(props, db, cache, userId, true);
}
return errors;
}
private boolean isNeedToPay(ChatAccount acc, Date curMonthStart){
if(acc.tariffStart.compareTo(curMonthStart) >= 0) return false;
return acc.tariffLastPay.before(curMonthStart);
}
/** проверить рассинхрон заблокированных акков и послать письмо админу */
public void checkAccBlocks() throws Exception {
List<UserBalance> list = universal.select(new GetAllBlockedUsers());
if(isEmpty(list)) return;
List<SyncBlockError> syncErrors = new ArrayList<>();
for (UserBalance userBalance : list) {
long userId = userBalance.userId;
Map<String, List<String>> unblockedAccs = getUnblockedAccs(userId);
if( ! isEmpty(unblockedAccs)){
for (Entry<String, List<String>> entry : unblockedAccs.entrySet()) {
String serverUrl = entry.getKey();
List<String> accs = entry.getValue();
syncErrors.add(new SyncBlockError(userId, serverUrl, accs));
}
}
}
if(syncErrors.size() > 0)
sendSyncErrorMailToAdmin("Unblocked accs error", syncErrors);
}
public void checkAccPaused() throws Exception {
Map<Long, ServerRow> servers = getServersMap(universal);
PausedStateResp state = getPausedState(universal);
checkAccPaused(servers, state.pausedAccs, true);
checkAccPaused(servers, state.unpausedAccs, false);
}
private void checkAccPaused(Map<Long, ServerRow> servers, Set<ChatAccount> accs, boolean expectedPaused){
HashSet<String> serverUrls = new HashSet<String>();
HashSet<String> uids = new HashSet<String>();
for (ChatAccount acc : accs) {
ServerRow server = servers.get(acc.serverId);
if(server == null) continue;
uids.add(acc.uid);
serverUrls.add(server.createUrl(URL_CHAT_GET_PAUSED_STATE));
}
Map<String, List<String>> errorAccs = new HashMap<String, List<String>>();
for(String url : serverUrls){
try {
GetPausedStateReq req = new GetPausedStateReq(uids, expectedPaused);
ResultAccsResp result = postEncryptedJson(props, url, req, ResultAccsResp.class);
if(result == null) continue;
if(isEmpty(result.accs)) continue;
errorAccs.put(url, result.accs);
}catch(Exception e){
ExpectedException.logError(log, e, "can't connect to "+url);
}
}
if( isEmpty(errorAccs)) return;
List<SyncPausedError> syncErrors = new ArrayList<>();
for (Entry<String, List<String>> entry : errorAccs.entrySet()) {
syncErrors.add(new SyncPausedError(entry.getKey(), expectedPaused, entry.getValue()));
}
String msg = expectedPaused? "Unpaused accs error" : "Paused accs error";
sendSyncErrorMailToAdmin(msg, syncErrors);
}
private Map<String, List<String>> getUnblockedAccs(long userId) {
List<ChatAccount> accs = db.chats.getOwnerAccsInfo(userId);
Map<String, List<String>> out = new HashMap<String, List<String>>();
HashSet<String> serverUrls = new HashSet<String>();
HashSet<String> uids = new HashSet<String>();
for (ChatAccount acc : accs) {
uids.add(acc.uid);
serverUrls.add(acc.server.createUrl(URL_CHAT_GET_UNBLOKED));
}
for(String url : serverUrls){
try {
ResultAccsResp result = postEncryptedJson(props, url, new GetUnblockedAccsReq(uids), ResultAccsResp.class);
if(result == null) continue;
if(isEmpty(result.accs)) continue;
out.put(url, result.accs);
}catch(Exception e){
ExpectedException.logError(log, e, "can't connect to "+url);
}
}
return out;
}
private void sendSyncErrorMailToAdmin(String title, List<?> syncErrors) {
if(isEmpty(syncErrors)) return;
if(props.getBoolVal(billing_sync_debug_DisableSendErrors)) return;
List<String> msgs = convert(syncErrors, (d)-> toJson(d, true));
mailService.sendAsyncWarnData(title, msgs);
}
public static class SyncPayError {
public Long userId;
public Set<ChatAccount> userAccs;
public String errorMsg;
public SyncPayError(Long userId, Set<ChatAccount> userAccs, Throwable t) {
this.userId = userId;
this.userAccs = userAccs;
this.errorMsg = printStackTrace(t);
}
}
public static class SyncBlockError {
public Long userId;
public String serverUrl;
public List<String> unblockedAccs;
public SyncBlockError(Long userId, String serverUrl,
List<String> unblockedAccs) {
super();
this.userId = userId;
this.serverUrl = serverUrl;
this.unblockedAccs = unblockedAccs;
}
}
public static class SyncPausedError {
public String serverUrl;
public boolean expectedPaused;
public List<String> accs;
public SyncPausedError(String serverUrl, boolean expectedPaused,
List<String> accs) {
this.serverUrl = serverUrl;
this.expectedPaused = expectedPaused;
this.accs = accs;
}
}
}