Preview:
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;
downloadDownload PNG downloadDownload JPEG downloadDownload SVG

Tip: You can change the style, width & colours of the snippet with the inspect tool before clicking Download!

Click to optimize width for Twitter