← 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,
},
};
}