BalancesService
Fri Sep 15 2023 20:15:01 GMT+0000 (Coordinated Universal Time)
Saved by @anirudhnair42 #typescript
import { ERepaymentReason, ESaleStatus, ESaleType, ESaleVersion, } from "@lendtable/common/dtos/SaleDto"; import { TTransaction, withTransaction } from "../utils/mysql"; import { calculatePayPeriodBalanceEspp, calculatePayPeriodBalanceK401, } from "@lendtable/common/config/dynamic-payouts"; import { isPaidOut } from "@lendtable/common/config/sale"; import { EBalanceContributionStatus } from "@lendtable/common/dtos/BalanceContributionDto"; import { EBalanceSnapshotType } from "@lendtable/common/dtos/BalanceSnapshotDto"; import { EPayoutTransactionStatus } from "@lendtable/common/dtos/PayoutDto"; import { EPlaidConnectionType } from "@lendtable/common/dtos/PlaidConnectionDto"; import { EStatusUpdateType } from "@lendtable/common/dtos/StatusUpdateDto"; import { PayPeriodStatus } from "@prisma/client"; import { Customer } from "../entities/Customer"; import { prisma } from "../prisma/prisma"; import BalanceContributionRepository from "../repositories/BalanceContributionRepository"; import BalanceSnapshotRepository from "../repositories/BalanceSnapshotRepository"; import CustomerRepository from "../repositories/CustomerRepository"; import PayoutRepository from "../repositories/PayoutRepository"; import PlaidConnectionRepository from "../repositories/PlaidConnectionRepository"; import SaleRepository from "../repositories/SaleRepository"; import HotShot from "../utils/hot-shot"; import { logger } from "../utils/logger"; import { REPAYMENTS_PRODUCT_ID, REPAYMENT_PLAN } from "../utils/stripe"; import { isClonedCustomer } from "../utils/user"; import PlaidService from "./PlaidService"; import SaleStatusService from "./SaleStatusService"; import SlackService from "./SlackService"; import StripeService from "./StripeService"; class BalancesService { // Recalculates the customer's lendtable balance public static async updateLendtableBalance( customer: Customer, t?: TTransaction ) { // Lendtable balance goes UP as customers get payouts, multiplied by the interest rate for each sale // Lendtable balance goes DOWN when the customer pays us back, or gets credit for special actions, i.e. we subtract the total (confirmed) balance contributions, in the order which they were confirmed (updated) // Combine the customer's sales and payouts, only choosing sales for which the contract is accepted and payouts that have not failed const sales = await SaleRepository.getAllByCustomerId( customer.id, "ASC", undefined, t ); const payouts = await PayoutRepository.getAllByCustomerId( customer.id, "ASC", undefined, t ); const payoutNonDynamic = payouts.filter((p) => { return p.payPeriodId === null; }); const payoutsNotFailed = payoutNonDynamic.filter((p) => { return ( p.payoutTransactionStatus === EPayoutTransactionStatus.Pending || p.payoutTransactionStatus === EPayoutTransactionStatus.Successful ); }); const payPeriods = await prisma.payPeriod.findMany({ // OK that this isn't in the same transaction, // because we never create payPeriods in this transaction // payPeriods are only created in Prisma where: { customerId: customer.id, status: PayPeriodStatus.APPROVED, }, orderBy: { payDateProjected: "desc", }, }); const salesWithPayoutsAndPayPeriods = sales.map((sale) => ({ sale, payouts: payoutsNotFailed.filter((p) => p.saleId === sale.id), payPeriods: payPeriods.filter((pp) => { return pp.esppSaleId === sale.id || pp.k401SaleId === sale.id; }), balanceContributions: [], })); // Go over the combined sales and sum the payouts const salesWithPayoutsSum = salesWithPayoutsAndPayPeriods.map((sp) => ({ ...sp, payoutsSumCents: sp.payouts.reduce( (acc, p) => acc + p.amountCents + p.subscriptionFromPayoutCents, 0 ), dynamicPayoutsSumCents: sp.payPeriods.reduce((acc, pp) => { if (sp.sale.saleType === ESaleType.K401) { return acc + (pp.k401PayoutAmount ?? 0); } else if (sp.sale.saleType === ESaleType.Espp) { return acc + (pp.esppPayoutAmount ?? 0); } else { throw new Error(`Invalid saleType ${sp.sale.id}`); } }, 0), })); // Apply the interest rate to the sum of the payouts const salesWithPayoutsSumInterest = salesWithPayoutsSum.map((sp) => ({ ...sp, payoutsSumWithInterestCents: Math.floor( sp.payoutsSumCents * (sp.sale.interestRatio() ?? 1) ), payPeriodSumWithOurShareCents: sp.payPeriods.reduce((acc, pp) => { const payoutSumForPeriod = payouts .filter((p) => { // conditional is different than above because we include scheduled payouts return ( p.payPeriodId === pp.id && p.payoutTransactionStatus !== EPayoutTransactionStatus.Failed ); }) .reduce((acc, p) => acc + p.amountCents, 0); if (payoutSumForPeriod === 0) { // If all payouts for this payPeriod have failed, then the payPeriod will not count towards balance return acc; } const shouldBePaidAmount = (pp.esppPayoutAmount ?? 0) + (pp.k401PayoutAmount ?? 0) - (pp.subscriptionFee ?? 0); if (payoutSumForPeriod !== shouldBePaidAmount) { // Edge case where the payPeriod has multiple payouts, and there is both succesful and failed payouts and we have not resent the failed payouts // We need to manually reduce the values on the payPeriod if (!isClonedCustomer(customer)) { void SlackService.postMessage({ text: `Pay Period ${pp.payDateProjectedStr} for customer ${customer.id} has multiple payouts, and there are both succesful and failed payouts.\n` + `The sum of payouts sent doesn't not match payoutAmounts stored on the payPeriod. We need to manually adjust.\n` + `Should be paid: $${shouldBePaidAmount / 100}, actual paid: $${ payoutSumForPeriod / 100 }.`, channel: SlackService.DYNAMIC_CHANNEL, }); } } if (sp.sale.saleType === ESaleType.K401) { // Use pp.k401OurShare once backfilled const taxRate = sp.payPeriods[0].taxRate; // use the taxRate from the most recent approved payPeriod return ( acc + calculatePayPeriodBalanceK401({ payPeriod: pp, taxRate }).balance ); } if (sp.sale.saleType === ESaleType.Espp) { // Use pp.esppOurShare once backfilled return acc + calculatePayPeriodBalanceEspp(pp).balance; } throw new Error("Invalid saleType"); }, 0), })); // Mark the payouts as being completed when enough money is sent to cover the loan amount, with a leeway of 10 USD for (const { sale, payoutsSumCents } of salesWithPayoutsSumInterest) { if ( sale.saleStatus !== ESaleStatus.PayoutScheduled && sale.saleStatus !== ESaleStatus.PayoutPaused ) { continue; } // Only legacy sales can move to PayoutComplete in this way if ( sale.saleVersion !== ESaleVersion._20220428 && sale.saleVersion !== ESaleVersion._20220505 ) { continue; } if ( payoutsSumCents > 0 && payoutsSumCents >= (sale.loanCents() ?? 0) - 10 * 100 ) { // Update SaleStatus const oldStatus = sale.saleStatus; sale.saleStatus = ESaleStatus.PayoutComplete; await SaleRepository.updateSaleStatus(sale.id, sale.saleStatus, t); await SaleStatusService.onStatusChanged( sale, customer, { oldStatus, statusUpdateType: EStatusUpdateType.PayoutsCompleted, recursionDepth: 0, }, t ); sale.repaymentReason = ERepaymentReason.ContractTermEnded; await SaleRepository.updateRepaymentReason( sale.id, sale.repaymentReason, t ); } } // To start off, make the balances equal to the payouts sum with interest const salesWithBalances = salesWithPayoutsSumInterest.map((sp) => ({ ...sp, balanceCents: sp.payoutsSumWithInterestCents + sp.payPeriodSumWithOurShareCents, })); // Apply balance contributions, draining the balances const balanceContributions = await BalanceContributionRepository.getAllByCustomerIdStatusOrderUpdated( customer.id, EBalanceContributionStatus.Confirmed, "ASC", undefined, t ); // Apply BalanceContributions that have a SaleId first // Refunds and disputes all have saleId set for (const balanceContribution of balanceContributions.filter( (b) => b.saleId !== null )) { const sb = salesWithBalances.find( (s) => s.sale.id === balanceContribution.saleId ); // TODO how should we handle the case where the sale is missing? if (!sb) { continue; } sb.balanceCents -= balanceContribution.amountCents; } // Apply BalanceContributions without a SaleId second for (const balanceContribution of balanceContributions.filter( (b) => b.saleId === null )) { const sb = salesWithBalances.find((sb) => sb.balanceCents > 0); if (!sb) { continue; } sb.balanceCents -= balanceContribution.amountCents; // Update the BalanceContribution's SaleId balanceContribution.saleId = sb.sale.id; await BalanceContributionRepository.updateSaleId( balanceContribution.id, balanceContribution.saleId, t ); } // Sum the total lendtable balance const lendtableBalanceCents = salesWithBalances.reduce( (acc, sb) => acc + sb.balanceCents, 0 ); // Sum total 401(k) and ESPP balances const lendtableBalanceK401Cents = salesWithBalances .filter((sb) => sb.sale.saleType === ESaleType.K401) .reduce((acc, sb) => acc + sb.balanceCents, 0); const lendtableBalanceEsppCents = salesWithBalances .filter((sb) => sb.sale.saleType === ESaleType.Espp) .reduce((acc, sb) => acc + sb.balanceCents, 0); // Save overall Customer balances customer.lendtableBalanceCents = lendtableBalanceCents; customer.lendtableBalanceK401Cents = lendtableBalanceK401Cents; customer.lendtableBalanceEsppCents = lendtableBalanceEsppCents; await CustomerRepository.updateLendtableBalances( customer.id, { lendtableBalanceCents, lendtableBalanceK401Cents, lendtableBalanceEsppCents, }, t ); // Save individual Sale balances for (const { sale, balanceCents, payoutsSumCents, dynamicPayoutsSumCents, } of salesWithBalances) { sale.balanceCents = balanceCents; sale.payoutSumCents = payoutsSumCents + dynamicPayoutsSumCents; } await Promise.all([ ...salesWithBalances.map((sb) => SaleRepository.updateBalanceCents(sb.sale.id, sb.sale.balanceCents, t) ), ...salesWithBalances.map((sb) => SaleRepository.updatePayoutSumCents( sb.sale.id, sb.sale.payoutSumCents, t ) ), ]); // Sales that have had been paid out and were drained to a balance of zero are changed to RepaymentComplete const salesToComplete = salesWithBalances.filter( (sb) => isPaidOut(sb.sale) && sb.balanceCents <= 10 ); for (const { sale } of salesToComplete) { if (sale.saleStatus === ESaleStatus.RepaymentComplete) { continue; } const customerRepaymentPlans = await StripeService.getCustomerRepaymentSubscriptions(customer); if (customerRepaymentPlans) { const saleRepaymentPlan = customerRepaymentPlans?.find( (rp) => rp.saleId === sale.id ); if (saleRepaymentPlan && saleRepaymentPlan.planStatus !== "canceled") { await StripeService.cancelSubscription({ subscriptionId: saleRepaymentPlan.id, stripeSubscriptionProductId: REPAYMENTS_PRODUCT_ID, stripeSubscriptionTypeMedata: REPAYMENT_PLAN, stripeSubscriptionSaleIdMetadata: sale.id, }); } } // Update SaleStatus const oldStatus = sale.saleStatus; sale.saleStatus = ESaleStatus.RepaymentComplete; await SaleRepository.updateSaleStatus(sale.id, sale.saleStatus, t); await SaleStatusService.onStatusChanged( sale, customer, { oldStatus, statusUpdateType: EStatusUpdateType.Repaid, recursionDepth: 0, }, t ); } // Remove preapproved status from sales with repaid parents. if (salesToComplete) { await this.removePreapprovedFromEsppSaleIfParentAndSiblingsRepaid( customer.id, t ); } } // Remove preapproved status from child sales if parent sale and sibling sales have been repaid // Call after some sales are repaid public static async removePreapprovedFromEsppSaleIfParentAndSiblingsRepaid( customerId: string, t?: TTransaction ) { const sales = await SaleRepository.getAllByCustomerId( customerId, undefined, undefined, t ); const preapprovedSales = sales.filter( (sale) => sale.saleType === ESaleType.Espp && sale.preapproved ); for (const childSale of preapprovedSales) { // check the parent sale and sibling sales if (!childSale.parentId) { continue; } const targetSales = await SaleRepository.getAllByParentId( childSale.parentId, undefined, undefined, t ); const allRepaid = targetSales.every((sale) => { return ( sale.preapproved || sale.saleStatus === ESaleStatus.RepaymentComplete ); }); if (allRepaid) { await SaleRepository.updatePreapproved(childSale.id, false, t); } } } // Recalculates the customer's 401(k) balance public static async updateK401Balance(customer: Customer, t?: TTransaction) { // The 401(k) balance is equal to what's in the customer's 401(k) account(s), we use Plaid to get that info const balance = await PlaidService.getCustomerBalance( customer, EPlaidConnectionType.K401, t ); if (balance === null) { return; } const balanceCents = Math.round(balance * 100); // Save balance snapshot to DB but only if it's changed since the last time we got the info await BalanceSnapshotRepository.createIfChanged( { customerId: customer.id, balanceSnapshotType: EBalanceSnapshotType.K401, amountCents: balanceCents, }, t ); } // Recalculates the customer's ESPP balance public static async updateEsppBalance(customer: Customer, t?: TTransaction) { // The ESPP balance is equal to what's in the customer's ESPP account(s), we use Plaid to get that info const balance = await PlaidService.getCustomerBalance( customer, EPlaidConnectionType.Espp, t ); if (balance === null) { return; } const balanceCents = Math.round(balance * 100); // Save balance snapshot to DB but only if it's changed since the last time we got the info await BalanceSnapshotRepository.createIfChanged( { customerId: customer.id, balanceSnapshotType: EBalanceSnapshotType.Espp, amountCents: balanceCents, }, t ); } // Recalculates the customer's checking account balance public static async updateCheckingBalance( customer: Customer, t?: TTransaction ) { // The ESPP balance is equal to what's in the customer's checking account(s), we use Plaid to get that info const balance = await PlaidService.getCustomerBalance( customer, EPlaidConnectionType.Checking, t ); if (balance === null) { return; } const balanceCents = Math.round(balance * 100); // Save balance snapshot to DB but only if it's changed since the last time we got the info await BalanceSnapshotRepository.createIfChanged( { customerId: customer.id, balanceSnapshotType: EBalanceSnapshotType.Checking, amountCents: balanceCents, }, t ); } public static async updateAllLendtableBalances() { logger.warn("updateAllLendtableBalances - started"); const customers = await CustomerRepository.getAllSignedUp(); for (const { id } of customers) { try { await withTransaction(async (t) => { const customer = await CustomerRepository.getById(id, t); if (!customer) { return; } await this.updateLendtableBalance(customer, t); }); } catch (err) { logger.error("BalancesService.updateAllLendtableBalances", err); } } logger.warn("updateAllLendtableBalances - complete"); } public static async updateAllK401Balances() { logger.info("updateAllK401Balances - started"); const connectionsK401 = await PlaidConnectionRepository.getAllByType( EPlaidConnectionType.K401 ); for (const { id } of connectionsK401) { HotShot.increment("BalancesService.updateAllK4O1Balances", { service: "cronjob", api: "plaid", }); try { await withTransaction(async (t) => { const connection = await PlaidConnectionRepository.getById(id, t); if (!connection) { return; } const customer = await CustomerRepository.getById( connection.customerId, t ); if (!customer) { return; } await this.updateK401Balance(customer, t); }); } catch (err) { logger.warn("BalancesService.updateAllK401Balances", err); HotShot.increment("BalancesService.updateAllK4O1Balances_error", { service: "cronjob", api: "plaid", }); HotShot.event( "BalancesService.updateAllK401Balances_error", `Error updating 401K balances for Plaid Connection: ${id}`, { aggregation_key: "cronjob", date_happened: new Date(), alert_type: "error", } ); } } logger.info("updateAllK401Balances - complete"); } public static async updateAllEsppBalances() { logger.info("updateAllEsppBalances - started"); const connectionsEspp = await PlaidConnectionRepository.getAllByType( EPlaidConnectionType.Espp ); for (const { id } of connectionsEspp) { HotShot.increment("BalancesService.updateAllEsppBalances", { service: "cronjob", api: "plaid", }); try { await withTransaction(async (t) => { const connection = await PlaidConnectionRepository.getById(id, t); if (!connection) { return; } const customer = await CustomerRepository.getById( connection.customerId, t ); if (!customer) { return; } await this.updateEsppBalance(customer, t); }); } catch (err) { logger.warn("BalancesService.updateAllEsppBalances", err); HotShot.increment("BalancesService.updateAllEsppBalances_error", { service: "cronjob", api: "plaid", }); HotShot.event( "BalancesService.updateAllEsppBalances_error", `Error updating ESPP balances for Plaid Connection: ${id}`, { aggregation_key: "cronjob", date_happened: new Date(), alert_type: "error", } ); } } logger.info("updateAllEsppBalances - complete"); } public static async updateAllCheckingBalances() { logger.info("updateAllCheckingBalances - started"); const connectionsChecking = await PlaidConnectionRepository.getAllByType( EPlaidConnectionType.Checking ); for (const { id } of connectionsChecking) { HotShot.increment("BalancesService.updateAllCheckingBalances", { service: "cronjob", api: "plaid", }); try { await withTransaction(async (t) => { const connection = await PlaidConnectionRepository.getById(id, t); if (!connection) { return; } const customer = await CustomerRepository.getById( connection.customerId, t ); if (!customer) { return; } await this.updateCheckingBalance(customer, t); }); } catch (err) { logger.warn("BalancesService.updateAllCheckingBalances", err); HotShot.increment("BalancesService.updateAllCheckingBalances_error", { service: "cronjob", api: "plaid", }); HotShot.event( "BalancesService.updateAllCheckingBalances_error", `Error updating checking balances for Plaid Connection: ${id}`, { aggregation_key: "cronjob", date_happened: new Date(), alert_type: "error", } ); } } logger.info("updateAllCheckingBalances - complete"); } } export default BalancesService;
Comments