Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.spice.profitmandi.service.user;

import com.spice.profitmandi.common.exception.ProfitMandiBusinessException;
import com.spice.profitmandi.common.model.ProfitMandiConstants;
import com.spice.profitmandi.dao.entity.auth.AuthUser;
import com.spice.profitmandi.dao.entity.cs.Position;
import com.spice.profitmandi.dao.entity.fofo.FofoStore;
import com.spice.profitmandi.dao.entity.fofo.StoreRevival;
import com.spice.profitmandi.dao.entity.fofo.StoreStatusLog;
import com.spice.profitmandi.dao.entity.transaction.Loan;
import com.spice.profitmandi.dao.entity.transaction.UserWallet;
import com.spice.profitmandi.dao.enumuration.cs.EscalationType;
import com.spice.profitmandi.dao.enumuration.fofo.StoreRevivalType;
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatus;
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusAction;
import com.spice.profitmandi.dao.enumuration.fofo.StoreStatusSource;
import com.spice.profitmandi.dao.repository.auth.AuthRepository;
import com.spice.profitmandi.dao.repository.cs.CsService;
import com.spice.profitmandi.dao.repository.cs.PositionRepository;
import com.spice.profitmandi.dao.repository.dtr.FofoStoreRepository;
import com.spice.profitmandi.dao.repository.fofo.StoreRevivalRepository;
import com.spice.profitmandi.dao.repository.fofo.StoreStatusLogRepository;
import com.spice.profitmandi.dao.repository.transaction.LoanRepository;
import com.spice.profitmandi.dao.repository.transaction.UserWalletHistoryRepository;
import com.spice.profitmandi.dao.repository.transaction.UserWalletRepository;
import com.spice.profitmandi.service.mail.MailOutboxService;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

/**
 * The only writer of a partner store's status (fofo_store.store_status, plus the legacy active / closed columns).
 * Every change checks the allowed transitions, who may make it and the mandatory reason, then writes a
 * fofo.store_status_log row. Pause, un-pause and suspend mail Tarun, Kamini and the partner's Sales L5 and above.
 */
@Service
public class StoreStatusService {

        private static final Logger LOGGER = LogManager.getLogger(StoreStatusService.class);

        static final String TARUN = "tarun.verma@smartdukaan.com";
        static final String KAMINI = "kamini.sharma@smartdukaan.com";
        static final String NEERAJ = "neeraj.gupta@smartdukaan.com";
        private static final List<String> NAMED = Collections.unmodifiableList(Arrays.asList(TARUN, KAMINI));

        public static final String ALLOWED_NAMED = "NAMED";
        public static final String ALLOWED_SALES_L5 = "SALES_L5+";
        public static final String ALLOWED_ACCOUNTS_L3 = "ACCOUNTS_L3+";
        public static final String ALLOWED_ACCOUNTS_L4 = "ACCOUNTS_L4+";
        public static final String ALLOWED_SYSTEM = "SYSTEM";
        public static final String ALLOWED_LOI = "LOI_APPROVAL";

        private static final int MAX_REASON = 1000;
        private static final int CLOSURE_REMINDER_DAYS = 7;
        private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy hh:mm a");
        private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MMM-yyyy");

        @Autowired
        private FofoStoreRepository fofoStoreRepository;

        @Autowired
        private StoreStatusLogRepository storeStatusLogRepository;

        @Autowired
        private StoreRevivalRepository storeRevivalRepository;

        @Autowired
        private AuthRepository authRepository;

        @Autowired
        private PositionRepository positionRepository;

        @Autowired
        private CsService csService;

        @Autowired
        private UserWalletRepository userWalletRepository;

        @Autowired
        private UserWalletHistoryRepository userWalletHistoryRepository;

        @Autowired
        private LoanRepository loanRepository;

        @Autowired
        private MailOutboxService mailOutboxService;

        // ── Status changes ────────────────────────────────────────────────────────────────────────────────────────

        /** ACTIVE → PAUSED (Tarun, Kamini, Sales L5+ for the partner) or SUSPENDED → PAUSED (activation rights). */
        public void pause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                String allowedAs = fofoStore.getStoreStatus() == StoreStatus.ACTIVE
                                ? allowedToPause(fofoId, changedBy) : allowedToChange(changedBy);
                changeStatus(fofoStore, StoreStatus.PAUSED, StoreStatusAction.PAUSE, reason, changedBy, allowedAs, source, null, null);
                sendStatusChangeMail(fofoStore, StoreStatusAction.PAUSE, reason, changedBy);
        }

        /** PAUSED → ACTIVE. */
        public void unpause(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                requireStatus(fofoStore, StoreStatus.PAUSED, "un-pause");
                String allowedAs = allowedToChange(changedBy);
                changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.UNPAUSE, reason, changedBy, allowedAs, source, null, null);
                sendStatusChangeMail(fofoStore, StoreStatusAction.UNPAUSE, reason, changedBy);
        }

        /** ACTIVE or PAUSED → SUSPENDED (nearing closure, logged out). */
        public void suspend(int fofoId, String reason, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                String allowedAs = allowedToChange(changedBy);
                changeStatus(fofoStore, StoreStatus.SUSPENDED, StoreStatusAction.SUSPEND, reason, changedBy, allowedAs, source, null, null);
                sendStatusChangeMail(fofoStore, StoreStatusAction.SUSPEND, reason, changedBy);
        }

        /** SUSPENDED → ACTIVE for good (a PAUSED store is un-paused instead). Ends any temporary activation. */
        public void activate(int fofoId, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                String allowedAs = allowedToChange(changedBy);
                if (fofoStore.getStoreStatus() == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {
                        // Temporarily active: make it permanent.
                        StoreStatus previous = fofoStore.getStoreStatus();
                        fofoStore.setActiveTimeStamp(null);
                        writeLog(fofoStore, previous, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);
                        return;
                }
                requireStatus(fofoStore, StoreStatus.SUSPENDED, "activate");
                changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.ACTIVATE, null, changedBy, allowedAs, source, null, null);
        }

        /**
         * PAUSED or SUSPENDED → ACTIVE until the start of today + {@code days}. The early-morning job then returns the
         * store to the status it had before.
         */
        public void activateTemporarily(int fofoId, int days, String changedBy, StoreStatusSource source) throws ProfitMandiBusinessException {
                if (days <= 0) {
                        throw new ProfitMandiBusinessException("Temporary Activation", "Invalid Days", "Please enter a number of days above zero");
                }
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                StoreStatus current = fofoStore.getStoreStatus();
                if (current != StoreStatus.PAUSED && current != StoreStatus.SUSPENDED) {
                        throw new ProfitMandiBusinessException("Temporary Activation", "Not Allowed",
                                        "Only a paused or suspended store can be activated temporarily; this store is " + current);
                }
                String allowedAs = allowedToChange(changedBy);
                LocalDateTime activeUntil = LocalDate.now().atStartOfDay().plusDays(days);
                changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.TEMP_ACTIVATE, null, changedBy, allowedAs, source, activeUntil, null);
                fofoStore.setActiveTimeStamp(activeUntil);
        }

        /** Cron: every temporarily active store whose period has ended goes back to the status it had before. */
        public int expireTemporaryActivations() throws ProfitMandiBusinessException {
                LocalDateTime now = LocalDateTime.now();
                int expired = 0;
                for (FofoStore fofoStore : fofoStoreRepository.selectByStoreStatus(StoreStatus.ACTIVE)) {
                        if (fofoStore.getActiveTimeStamp() == null || fofoStore.getActiveTimeStamp().isAfter(now)) {
                                continue;
                        }
                        StoreStatusLog tempLog = storeStatusLogRepository.selectLatest(fofoStore.getId(), StoreStatusAction.TEMP_ACTIVATE);
                        StoreStatus previous = tempLog != null && tempLog.getFromStatus() != null ? tempLog.getFromStatus() : StoreStatus.SUSPENDED;
                        changeStatus(fofoStore, previous, StoreStatusAction.TEMP_EXPIRED, null, "checkPartnerActiveStore",
                                        ALLOWED_SYSTEM, StoreStatusSource.CRON, null, null);
                        expired++;
                }
                LOGGER.info("Temporary activations expired: {}", expired);
                return expired;
        }

        // ── Closure ───────────────────────────────────────────────────────────────────────────────────────────────

        /**
         * Conditions not met for closing this store, empty when it can be closed: SUSPENDED, wallet at ₹0 and no unsettled
         * loan (limit blocks included). Who is closing is checked separately by {@link #allowedToClose(String)}.
         */
        public List<String> closeBlockers(FofoStore fofoStore) throws ProfitMandiBusinessException {
                List<String> blockers = new ArrayList<>();
                if (fofoStore.getStoreStatus() != StoreStatus.SUSPENDED) {
                        blockers.add("Store must be suspended first (it is " + fofoStore.getStoreStatus() + ")");
                }
                long wallet = walletBalance(fofoStore.getId());
                if (wallet != 0) {
                        blockers.add("Wallet balance must be ₹0 (it is ₹" + wallet + ")");
                }
                List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
                if (!loans.isEmpty()) {
                        blockers.add("All loans must be settled (" + loans.size() + " open, ₹" + pendingTotal(loans) + " pending)");
                }
                return blockers;
        }

        /** Throws unless the store can be closed by {@code closedBy}; returns the role that allows it. */
        public String assertCanClose(FofoStore fofoStore, String closedBy) throws ProfitMandiBusinessException {
                String allowedAs = allowedToClose(closedBy);
                if (fofoStore.isClosed()) {
                        throw new ProfitMandiBusinessException("Store Closed", "Store Already Closed", "This store is already permanently closed");
                }
                List<String> blockers = closeBlockers(fofoStore);
                if (!blockers.isEmpty()) {
                        throw new ProfitMandiBusinessException("Store Closure", "Closure Not Allowed", String.join("; ", blockers));
                }
                return allowedAs;
        }

        /** SUSPENDED → CLOSED. Called by StoreClosureService once the closure record exists. */
        void markClosed(FofoStore fofoStore, String remark, String closedBy, String allowedAs, StoreStatusSource source,
                                        int storeClosureId) throws ProfitMandiBusinessException {
                changeStatus(fofoStore, StoreStatus.CLOSED, StoreStatusAction.CLOSE, remark, closedBy, allowedAs, source, null, storeClosureId);
                fofoStore.setClosedTimestamp(LocalDateTime.now());
        }

        // ── Revival ───────────────────────────────────────────────────────────────────────────────────────────────

        /** Only SUSPENDED (same store) and CLOSED (new store) partners can be revived. */
        public void assertRevivable(FofoStore fofoStore) throws ProfitMandiBusinessException {
                StoreStatus status = fofoStore.getStoreStatus();
                if (status != StoreStatus.SUSPENDED && status != StoreStatus.CLOSED) {
                        throw new ProfitMandiBusinessException("Revival", "Not Allowed", "Store " + fofoStore.getCode() + " is " + status
                                        + ". Only suspended or closed stores can be revived; reactivate or un-pause it instead.");
                }
        }

        /** SUSPENDED → ACTIVE with the same code, once the revival payment is approved. */
        public void reviveSameStore(int fofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {
                FofoStore fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                requireStatus(fofoStore, StoreStatus.SUSPENDED, "revive in place");
                changeStatus(fofoStore, StoreStatus.ACTIVE, StoreStatusAction.REVIVAL, null, revivedBy, ALLOWED_LOI,
                                StoreStatusSource.LOI_REVIVAL, null, null);
                persistRevival(fofoId, fofoId, StoreRevivalType.SAME_STORE, loiId, revivedBy);
        }

        /** A CLOSED partner was given a new store (new code); the old one stays CLOSED. Bookkeeping only. */
        public void recordNewStoreRevival(int oldFofoId, int newFofoId, Integer loiId, String revivedBy) throws ProfitMandiBusinessException {
                FofoStore oldStore = fofoStoreRepository.selectByRetailerId(oldFofoId);
                requireStatus(oldStore, StoreStatus.CLOSED, "revive as a new store");
                FofoStore newStore = fofoStoreRepository.selectByRetailerId(newFofoId);
                writeLog(newStore, null, StoreStatusAction.REVIVAL, "New store for closed " + oldStore.getCode(), revivedBy,
                                ALLOWED_LOI, StoreStatusSource.LOI_REVIVAL, null, null);
                persistRevival(oldFofoId, newFofoId, StoreRevivalType.NEW_STORE, loiId, revivedBy);
        }

        // ── Wallet ────────────────────────────────────────────────────────────────────────────────────────────────

        /** Call at partner-initiated spends only; system, admin and cron debits must not call this. */
        public void assertCanSpendWallet(int fofoId) throws ProfitMandiBusinessException {
                FofoStore fofoStore;
                try {
                        fofoStore = fofoStoreRepository.selectByRetailerId(fofoId);
                } catch (ProfitMandiBusinessException e) {
                        return; // a retailer without a partner store
                }
                StoreStatus status = fofoStore.getStoreStatus();
                if (!status.canSpendWallet()) {
                        throw new ProfitMandiBusinessException("Wallet", "Wallet Blocked", status == StoreStatus.PAUSED
                                        ? "Your store is paused, so wallet payments are not allowed. Please contact your sales manager."
                                        : "Your store is " + status.name().toLowerCase() + ", so wallet payments are not allowed.");
                }
        }

        // ── History and permissions for the UI ────────────────────────────────────────────────────────────────────

        public List<StoreStatusLog> history(int fofoId) {
                return storeStatusLogRepository.selectByFofoId(fofoId);
        }

        /** Actions {@code emailId} may take on this store right now (FSM and permission; closure conditions excluded). */
        public Set<StoreStatusAction> allowedActions(FofoStore fofoStore, String emailId) {
                Set<StoreStatusAction> actions = EnumSet.noneOf(StoreStatusAction.class);
                StoreStatus status = fofoStore.getStoreStatus();
                boolean change = isAllowed(() -> allowedToChange(emailId));
                if (status == StoreStatus.ACTIVE && isAllowed(() -> allowedToPause(fofoStore.getId(), emailId))
                                || status == StoreStatus.SUSPENDED && change) {
                        actions.add(StoreStatusAction.PAUSE);
                }
                if (change) {
                        if (status == StoreStatus.PAUSED) {
                                actions.add(StoreStatusAction.UNPAUSE);
                        }
                        if (status == StoreStatus.ACTIVE || status == StoreStatus.PAUSED) {
                                actions.add(StoreStatusAction.SUSPEND);
                        }
                        if (status == StoreStatus.SUSPENDED || status == StoreStatus.ACTIVE && fofoStore.getActiveTimeStamp() != null) {
                                actions.add(StoreStatusAction.ACTIVATE);
                        }
                        if (status == StoreStatus.PAUSED || status == StoreStatus.SUSPENDED) {
                                actions.add(StoreStatusAction.TEMP_ACTIVATE);
                        }
                }
                if (status == StoreStatus.SUSPENDED && isAllowed(() -> allowedToClose(emailId))) {
                        actions.add(StoreStatusAction.CLOSE);
                }
                return actions;
        }

        /**
         * Which buttons a store list may show to {@code emailId}: keys pause, change and close. Pause is only a hint here
         * (Sales L5+ anywhere); each action re-checks the partner coverage itself.
         */
        public Map<String, Boolean> listPermissions(String emailId) throws ProfitMandiBusinessException {
                String email = normalise(emailId);
                Map<String, Boolean> permissions = new HashMap<>();
                permissions.put("pause", NAMED.contains(email)
                                || holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_SALES, EscalationType.L5));
                permissions.put("change", isAllowed(() -> allowedToChange(email)));
                permissions.put("close", isAllowed(() -> allowedToClose(email)));
                return permissions;
        }

        /** Pause: Tarun, Kamini, or Sales L5 and above who cover this partner. */
        public String allowedToPause(int fofoId, String emailId) throws ProfitMandiBusinessException {
                String email = normalise(emailId);
                if (NAMED.contains(email)) {
                        return ALLOWED_NAMED;
                }
                boolean coversPartner = csService.getAuthUsersByPartnerIdAndCategory(fofoId, ProfitMandiConstants.TICKET_CATEGORY_SALES,
                                levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull)
                                .anyMatch(x -> x.equalsIgnoreCase(email));
                if (coversPartner) {
                        return ALLOWED_SALES_L5;
                }
                throw denied("pause this store");
        }

        /** Suspend, activate, un-pause, temporary activate: Tarun, Kamini, Neeraj, or Accounts L4 and above. */
        public String allowedToChange(String emailId) throws ProfitMandiBusinessException {
                String email = normalise(emailId);
                if (NAMED.contains(email) || NEERAJ.equals(email)) {
                        return ALLOWED_NAMED;
                }
                if (holdsAccountsLevel(email, EscalationType.L4)) {
                        return ALLOWED_ACCOUNTS_L4;
                }
                throw denied("change this store's status");
        }

        /** Close: Tarun, Kamini, or Accounts L3 and above. */
        public String allowedToClose(String emailId) throws ProfitMandiBusinessException {
                String email = normalise(emailId);
                if (NAMED.contains(email)) {
                        return ALLOWED_NAMED;
                }
                if (holdsAccountsLevel(email, EscalationType.L3)) {
                        return ALLOWED_ACCOUNTS_L3;
                }
                throw denied("close store");
        }

        // ── Weekly closure reminder ───────────────────────────────────────────────────────────────────────────────

        /**
         * Monday digest of stores SUSPENDED for more than 7 days, To Neeraj with the other closers in CC. Stores whose
         * only entry is the migration baseline were already inactive before the log existed, so they are always listed.
         */
        public int sendClosureReminder() throws ProfitMandiBusinessException {
                List<FofoStore> suspended = fofoStoreRepository.selectByStoreStatus(StoreStatus.SUSPENDED);
                Map<Integer, StoreStatusLog> entries = storeStatusLogRepository.selectLatestEntries(
                                suspended.stream().map(FofoStore::getId).collect(Collectors.toList()), StoreStatus.SUSPENDED);
                LocalDateTime cutoff = LocalDateTime.now().minusDays(CLOSURE_REMINDER_DAYS);

                StringBuilder rows = new StringBuilder();
                int listed = 0;
                int closable = 0;
                for (FofoStore fofoStore : suspended) {
                        StoreStatusLog entry = entries.get(fofoStore.getId());
                        boolean baseline = entry == null || entry.getAction() == StoreStatusAction.BASELINE;
                        if (!baseline && entry.getCreateTimestamp().isAfter(cutoff)) {
                                continue;
                        }
                        long wallet = walletBalance(fofoStore.getId());
                        List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
                        boolean canClose = wallet == 0 && loans.isEmpty();
                        String since = baseline
                                        ? "before " + (entry == null ? "-" : entry.getCreateTimestamp().format(DATE_FORMAT))
                                        : entry.getCreateTimestamp().format(DATE_FORMAT) + " ("
                                        + ChronoUnit.DAYS.between(entry.getCreateTimestamp().toLocalDate(), LocalDate.now()) + " days)";
                        rows.append("<tr><td>").append(fofoStore.getCode()).append("</td><td>")
                                        .append(escape(Optional.ofNullable(fofoStore.getOutletName()).orElse("-"))).append("</td><td>")
                                        .append(since).append("</td><td align=\"right\">").append(wallet).append("</td><td align=\"right\">")
                                        .append(loans.isEmpty() ? "-" : loans.size() + " / ₹" + pendingTotal(loans)).append("</td><td>")
                                        .append(canClose ? "<b>Yes</b>" : "No").append("</td></tr>");
                        listed++;
                        if (canClose) {
                                closable++;
                        }
                }
                if (listed == 0) {
                        LOGGER.info("Closure reminder: no store suspended for more than {} days", CLOSURE_REMINDER_DAYS);
                        return 0;
                }
                Set<String> cc = new LinkedHashSet<>(NAMED);
                cc.addAll(accountsEmailsFrom(EscalationType.L3));
                cc.remove(NEERAJ);
                String subject = "Suspended stores pending closure - " + listed + " stores, " + closable + " closable now";
                String body = "<p>Dear Neeraj,</p><p>These stores have been suspended for more than " + CLOSURE_REMINDER_DAYS
                                + " days. A store can be closed once its wallet is at ₹0 and every loan, including limit blocks, is settled.</p>"
                                + "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\" style=\"border-collapse:collapse\">"
                                + "<tr><th>Store</th><th>Name</th><th>Suspended since</th><th>Wallet ₹</th><th>Open loans</th><th>Closable now</th></tr>"
                                + rows + "</table><p>Regards,<br>SmartDukaan</p>";
                mailOutboxService.queueMail(new String[]{NEERAJ}, cc.toArray(new String[0]), null, subject, body, true,
                                "StoreStatusService.sendClosureReminder");
                LOGGER.info("Closure reminder queued: {} stores, {} closable", listed, closable);
                return listed;
        }

        // ── Internals ─────────────────────────────────────────────────────────────────────────────────────────────

        private void changeStatus(FofoStore fofoStore, StoreStatus target, StoreStatusAction action, String reason,
                                                          String changedBy, String allowedAs, StoreStatusSource source, LocalDateTime activeUntil,
                                                          Integer storeClosureId) throws ProfitMandiBusinessException {
                StoreStatus from = fofoStore.getStoreStatus();
                if (!from.canMoveTo(target)) {
                        throw new ProfitMandiBusinessException("Store Status", "Not Allowed",
                                        "Store " + fofoStore.getCode() + " cannot move from " + from + " to " + target);
                }
                String cleanReason = reason == null ? null : reason.trim();
                if (action.needsReason() && (cleanReason == null || cleanReason.isEmpty())) {
                        throw new ProfitMandiBusinessException("Store Status", "Reason Missing",
                                        "Please give a reason to " + action.getLabel().toLowerCase() + " this store");
                }
                if (cleanReason != null && cleanReason.length() > MAX_REASON) {
                        throw new ProfitMandiBusinessException("Store Status", "Reason Too Long",
                                        "Please keep the reason within " + MAX_REASON + " characters");
                }
                fofoStore.setStoreStatus(target);
                if (action != StoreStatusAction.TEMP_ACTIVATE) {
                        fofoStore.setActiveTimeStamp(null);
                }
                writeLog(fofoStore, from, action, cleanReason, changedBy, allowedAs, source, activeUntil, storeClosureId);
                LOGGER.info("Store {} {} -> {} ({}) by {} as {}", fofoStore.getCode(), from, target, action, changedBy, allowedAs);
        }

        private void writeLog(FofoStore fofoStore, StoreStatus from, StoreStatusAction action, String reason, String changedBy,
                                                  String allowedAs, StoreStatusSource source, LocalDateTime activeUntil, Integer storeClosureId) {
                StoreStatusLog log = new StoreStatusLog();
                log.setFofoId(fofoStore.getId());
                log.setFromStatus(from);
                log.setToStatus(fofoStore.getStoreStatus());
                log.setAction(action);
                log.setReason(reason);
                log.setSource(source);
                log.setChangedBy(changedBy);
                log.setAllowedAs(allowedAs);
                log.setActiveUntil(activeUntil);
                log.setStoreClosureId(storeClosureId);
                log.setCreateTimestamp(LocalDateTime.now());
                storeStatusLogRepository.persist(log);
        }

        private void persistRevival(int oldFofoId, int newFofoId, StoreRevivalType type, Integer loiId, String revivedBy) {
                StoreRevival revival = new StoreRevival();
                revival.setOldFofoId(oldFofoId);
                revival.setNewFofoId(newFofoId);
                revival.setRevivalType(type);
                revival.setLoiId(loiId);
                revival.setRevivedBy(revivedBy);
                revival.setCreateTimestamp(LocalDateTime.now());
                storeRevivalRepository.persist(revival);
                LOGGER.info("Store revival recorded: {}", revival);
        }

        private void sendStatusChangeMail(FofoStore fofoStore, StoreStatusAction action, String reason, String changedBy)
                        throws ProfitMandiBusinessException {
                Set<String> to = new LinkedHashSet<>(NAMED);
                csService.getAuthUsersByPartnerIdAndCategory(fofoStore.getId(), ProfitMandiConstants.TICKET_CATEGORY_SALES,
                                levelsFrom(EscalationType.L5)).stream().map(AuthUser::getEmailId).filter(Objects::nonNull)
                                .map(String::toLowerCase).forEach(to::add);
                StoreStatusLog log = storeStatusLogRepository.selectLatest(fofoStore.getId(), action);
                List<Loan> loans = loanRepository.selectAllOpenLoanIncludingLimitBlock(fofoStore.getId());
                String name = Optional.ofNullable(fofoStore.getOutletName()).orElse("-");

                String subject = "Store " + action.getLabel() + " - " + fofoStore.getCode() + " - " + name;
                StringBuilder body = new StringBuilder();
                body.append("Dear Team,\n\nA partner store's status has changed.\n\n");
                body.append("Partner       : ").append(name).append(" (Partner Id ").append(fofoStore.getId()).append(")\n");
                body.append("Store Code    : ").append(fofoStore.getCode()).append("\n");
                body.append("Status        : ").append(log == null ? "-" : log.getFromStatus()).append(" -> ")
                                .append(fofoStore.getStoreStatus()).append("\n");
                body.append("Reason        : ").append(reason.trim()).append("\n");
                body.append("Changed By    : ").append(changedBy).append("\n");
                body.append("Changed On    : ").append(LocalDateTime.now().format(TIME_FORMAT)).append("\n");
                body.append("Wallet        : ₹").append(walletBalance(fofoStore.getId())).append("\n");
                body.append("Open Loans    : ").append(loans.isEmpty() ? "None" : loans.size() + " (₹" + pendingTotal(loans) + " pending)")
                                .append("\n\nRegards,\nSmartDukaan");
                mailOutboxService.queueMail(to.toArray(new String[0]), null, subject, body.toString(),
                                "StoreStatusService.sendStatusChangeMail");
        }

        private void requireStatus(FofoStore fofoStore, StoreStatus required, String verb) throws ProfitMandiBusinessException {
                if (fofoStore.getStoreStatus() != required) {
                        throw new ProfitMandiBusinessException("Store Status", "Not Allowed", "Cannot " + verb + " store "
                                        + fofoStore.getCode() + ": it is " + fofoStore.getStoreStatus() + ", not " + required);
                }
        }

        /** Rounded to the rupee; read without creating a wallet row. */
        private long walletBalance(int fofoId) throws ProfitMandiBusinessException {
                UserWallet wallet = userWalletRepository.selectByRetailerId(fofoId);
                return wallet == null ? 0 : Math.round(userWalletHistoryRepository.selectSumByWallet(wallet.getId()));
        }

        private static long pendingTotal(List<Loan> loans) {
                return loans.stream().map(Loan::getPendingAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)
                                .setScale(0, BigDecimal.ROUND_HALF_UP).longValue();
        }

        private boolean holdsAccountsLevel(String email, EscalationType minimum) throws ProfitMandiBusinessException {
                return holdsLevel(email, ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS, minimum);
        }

        private boolean holdsLevel(String email, int categoryId, EscalationType minimum) throws ProfitMandiBusinessException {
                AuthUser authUser;
                try {
                        authUser = authRepository.selectByEmailOrMobile(email);
                } catch (ProfitMandiBusinessException e) {
                        return false;
                }
                if (!Boolean.TRUE.equals(authUser.getActive())) {
                        return false;
                }
                return positionRepository.selectPositionByAuthId(authUser.getId()).stream()
                                .anyMatch(x -> x.getCategoryId() == categoryId
                                                && x.getEscalationType() != null && x.getEscalationType().compareTo(minimum) >= 0);
        }

        private Set<String> accountsEmailsFrom(EscalationType minimum) {
                List<Integer> authIds = positionRepository.selectPositionByCategoryId(ProfitMandiConstants.TICKET_CATEGORY_ACCOUNTS)
                                .stream().filter(x -> x.getAuthUserId() > 0 && x.getEscalationType() != null
                                                && x.getEscalationType().compareTo(minimum) >= 0)
                                .map(Position::getAuthUserId).distinct().collect(Collectors.toList());
                if (authIds.isEmpty()) {
                        return new LinkedHashSet<>();
                }
                return authRepository.selectByIds(authIds).stream().filter(x -> Boolean.TRUE.equals(x.getActive()))
                                .map(AuthUser::getEmailId).filter(Objects::nonNull).map(String::toLowerCase)
                                .collect(Collectors.toCollection(LinkedHashSet::new));
        }

        private static EscalationType[] levelsFrom(EscalationType minimum) {
                return Arrays.stream(EscalationType.values()).filter(x -> x.compareTo(minimum) >= 0).toArray(EscalationType[]::new);
        }

        private static String normalise(String emailId) {
                return emailId == null ? "" : emailId.trim().toLowerCase();
        }

        private static ProfitMandiBusinessException denied(String action) {
                return new ProfitMandiBusinessException("Access Denied", "Unauthorised Access", "You are not authorise to " + action);
        }

        private static String escape(String text) {
                return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
        }

        private interface PermissionCheck {
                String check() throws ProfitMandiBusinessException;
        }

        private static boolean isAllowed(PermissionCheck check) {
                try {
                        check.check();
                        return true;
                } catch (ProfitMandiBusinessException e) {
                        return false;
                }
        }
}