← back to Govarbitrage

src/engines/costs.ts

163 lines

import { round2, clamp } from "@/lib/utils";
import {
  BUYER_PREMIUM_RATE,
  CONDITION_REPAIR_FACTOR,
  LISTING_LABOR_COST,
  MARKETPLACE_FEE_RATE,
  PACKING_BASE,
  PACKING_PER_LB,
  PAYMENT_FEE_RATE,
  PHOTOGRAPHY_COST,
  PICKUP_LABOR_HOURLY,
  STORAGE_PER_MONTH,
  TARGET_ROI,
  TESTING_COST_PER_UNIT,
} from "./constants";
import { estimateFreight } from "./freight";
import type { CostBreakdown, CostInput } from "./types";

/** Costs that do NOT depend on the winning bid (prep, logistics, fees). */
function fixedCosts(input: CostInput) {
  const qty = Math.max(1, input.quantity || 1);
  const weight = Math.max(0, input.weightLbs || 0);
  const totalWeight = weight; // weightLbs is the lot total in our model

  const fr = estimateFreight(totalWeight);
  const shipping = input.localPickup ? 0 : fr.parcelShipping;
  const freight = input.localPickup ? 0 : fr.freight;

  const insurance = round2(input.expectedSalePrice * 0.01);
  const packing = round2(PACKING_BASE + PACKING_PER_LB * totalWeight);
  const pickupLabor = input.localPickup
    ? round2(PICKUP_LABOR_HOURLY * (1 + totalWeight / 500)) // ~1h + weight handling
    : 0;
  const testing = round2(TESTING_COST_PER_UNIT * qty);
  const repairs =
    input.repairsOverride ??
    round2(CONDITION_REPAIR_FACTOR[input.condition] * input.expectedSalePrice);
  // Certification only meaningful for regulated categories; default 0, overridable.
  const certification = 0;
  const months = Math.max(0.25, input.daysUntilSold / 30);
  const storage = round2(STORAGE_PER_MONTH * months);
  const photography = PHOTOGRAPHY_COST;
  const listingLabor = LISTING_LABOR_COST;

  // Resale-side fees scale with sale price, not bid.
  const marketplaceFees = round2(input.expectedSalePrice * MARKETPLACE_FEE_RATE);
  const paymentFees = round2(input.expectedSalePrice * PAYMENT_FEE_RATE);

  const fixedTotal = round2(
    shipping +
      freight +
      insurance +
      packing +
      pickupLabor +
      testing +
      repairs +
      certification +
      storage +
      photography +
      listingLabor,
  );

  return {
    shipping,
    freight,
    insurance,
    packing,
    pickupLabor,
    testing,
    repairs,
    certification,
    storage,
    photography,
    listingLabor,
    marketplaceFees,
    paymentFees,
    fixedTotal,
    freightBasis: fr.basis,
  };
}

/**
 * Full cost + profitability breakdown for a listing at a given winning bid.
 * Also back-solves `recommendedMaxBid`: the highest bid that still clears the
 * TARGET_ROI threshold given all bid-independent costs and resale fees.
 */
export function computeCosts(input: CostInput): CostBreakdown {
  const source = input.source;
  const premiumRate = BUYER_PREMIUM_RATE[source] ?? 0.1;
  const taxRate = input.purchaseTaxRate ?? 0; // resale cert typically zeroes this
  const f = fixedCosts(input);

  const bidCost = (bid: number) => {
    const buyerPremium = round2(bid * premiumRate);
    const salesTax = round2((bid + buyerPremium) * taxRate);
    return { buyerPremium, salesTax, acquire: round2(bid + buyerPremium + salesTax) };
  };

  const { buyerPremium, salesTax, acquire } = bidCost(input.winningBid);

  const expectedReturns = round2(
    input.expectedSalePrice - f.marketplaceFees - f.paymentFees,
  );
  const totalInvestment = round2(acquire + f.fixedTotal);
  const expectedNetProfit = round2(expectedReturns - totalInvestment);
  const roi = totalInvestment > 0 ? round2(expectedNetProfit / totalInvestment) : 0;

  // Annualize the ROI over the expected holding period.
  const days = Math.max(1, input.daysUntilSold);
  const annualizedReturn =
    roi > -1
      ? round2(Math.pow(1 + roi, 365 / days) - 1)
      : -1;

  // Recommended max bid: solve netProfit(bid) = TARGET_ROI * totalInvestment(bid).
  // netProfit = expectedReturns - (bid*(1+prem)*(1+tax) + fixedTotal)
  // Set roi target: (expectedReturns - acquire - fixed) / (acquire + fixed) = TARGET
  //   => expectedReturns - acquire - fixed = TARGET*(acquire + fixed)
  //   => expectedReturns = (1+TARGET)*(acquire + fixed)
  //   => acquire = expectedReturns/(1+TARGET) - fixed
  // acquire = bid*(1+prem)*(1+tax)  => bid = acquire / ((1+prem)*(1+tax))
  const acquireBudget =
    expectedReturns / (1 + TARGET_ROI) - f.fixedTotal;
  const bidMultiplier = (1 + premiumRate) * (1 + taxRate);
  const recommendedMaxBid = round2(
    clamp(acquireBudget / bidMultiplier, 0, Number.MAX_SAFE_INTEGER),
  );

  return {
    winningBid: round2(input.winningBid),
    buyerPremium,
    salesTax,
    shipping: f.shipping,
    freight: f.freight,
    insurance: f.insurance,
    packing: f.packing,
    pickupLabor: f.pickupLabor,
    testing: f.testing,
    repairs: f.repairs,
    certification: f.certification,
    marketplaceFees: f.marketplaceFees,
    paymentFees: f.paymentFees,
    storage: f.storage,
    photography: f.photography,
    listingLabor: f.listingLabor,
    expectedReturns,
    totalInvestment,
    expectedNetProfit,
    roi,
    annualizedReturn,
    recommendedMaxBid,
    assumptions: {
      buyerPremiumRate: premiumRate,
      purchaseTaxRate: taxRate,
      marketplaceFeeRate: MARKETPLACE_FEE_RATE,
      paymentFeeRate: PAYMENT_FEE_RATE,
      targetRoi: TARGET_ROI,
      freightBasis: f.freightBasis,
      holdingDays: days,
    },
  };
}