← back to Govarbitrage
Phase 2: deterministic valuation/cost/scoring engines + 16 passing Vitest tests
111d8f1d07d1579603019d38d9c6e16d68b42ac9 · 2026-07-09 22:27:46 -0700 · Steve Abrams
Files touched
A src/engines/constants.tsA src/engines/costs.test.tsA src/engines/costs.tsA src/engines/freight.tsA src/engines/scoring.test.tsA src/engines/scoring.tsA src/engines/types.tsA src/engines/valuation.test.tsA src/engines/valuation.tsA src/lib/db.tsA src/lib/utils.tsA vitest.config.ts
Diff
commit 111d8f1d07d1579603019d38d9c6e16d68b42ac9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 9 22:27:46 2026 -0700
Phase 2: deterministic valuation/cost/scoring engines + 16 passing Vitest tests
---
src/engines/constants.ts | 56 ++++++++++++
src/engines/costs.test.ts | 60 +++++++++++++
src/engines/costs.ts | 162 ++++++++++++++++++++++++++++++++++
src/engines/freight.ts | 47 ++++++++++
src/engines/scoring.test.ts | 72 ++++++++++++++++
src/engines/scoring.ts | 196 ++++++++++++++++++++++++++++++++++++++++++
src/engines/types.ts | 133 ++++++++++++++++++++++++++++
src/engines/valuation.test.ts | 85 ++++++++++++++++++
src/engines/valuation.ts | 84 ++++++++++++++++++
src/lib/db.ts | 13 +++
src/lib/utils.ts | 50 +++++++++++
vitest.config.ts | 15 ++++
12 files changed, 973 insertions(+)
diff --git a/src/engines/constants.ts b/src/engines/constants.ts
new file mode 100644
index 0000000..73e70c6
--- /dev/null
+++ b/src/engines/constants.ts
@@ -0,0 +1,56 @@
+import type { ConditionKey, SourceKey } from "./types";
+
+// Fraction of *new retail* a used unit fetches, by condition. These are the
+// backbone of the valuation model; documented so estimates are auditable.
+export const CONDITION_RETAIL_FACTOR: Record<ConditionKey, number> = {
+ NEW: 0.78,
+ LIKE_NEW: 0.62,
+ USED_GOOD: 0.46,
+ USED_FAIR: 0.31,
+ FOR_PARTS: 0.12,
+ UNKNOWN: 0.35,
+};
+
+// Typical repair burden as a fraction of new retail, by condition.
+export const CONDITION_REPAIR_FACTOR: Record<ConditionKey, number> = {
+ NEW: 0,
+ LIKE_NEW: 0.01,
+ USED_GOOD: 0.03,
+ USED_FAIR: 0.07,
+ FOR_PARTS: 0.18,
+ UNKNOWN: 0.05,
+};
+
+// Buyer's premium charged by each source (fraction of hammer/winning bid).
+export const BUYER_PREMIUM_RATE: Record<SourceKey, number> = {
+ GOVDEALS: 0.1,
+ PUBLIC_SURPLUS: 0.1,
+ GSA_AUCTIONS: 0,
+ COUNTY: 0.1,
+ STATE_SURPLUS: 0.08,
+ UNIVERSITY_SURPLUS: 0.1,
+ MUNICIBID: 0.1,
+ BID4ASSETS: 0.1,
+ CSV: 0.1,
+ EXTENSION: 0.1,
+ OTHER: 0.1,
+};
+
+// Marketplace + payment fee rates (fraction of resale price).
+export const MARKETPLACE_FEE_RATE = 0.132; // eBay-typical final value fee
+export const PAYMENT_FEE_RATE = 0.03; // managed-payments processing
+
+// Prep/handling cost assumptions (US dollars).
+export const PACKING_BASE = 12;
+export const PACKING_PER_LB = 0.15;
+export const PHOTOGRAPHY_COST = 8;
+export const LISTING_LABOR_COST = 10;
+export const TESTING_COST_PER_UNIT = 15;
+export const PICKUP_LABOR_HOURLY = 35;
+export const STORAGE_PER_MONTH = 18;
+
+// Weight threshold (lbs) above which an item ships as LTL freight, not parcel.
+export const FREIGHT_THRESHOLD_LBS = 150;
+
+// Target ROI used to back-solve the recommended maximum bid.
+export const TARGET_ROI = 0.4;
diff --git a/src/engines/costs.test.ts b/src/engines/costs.test.ts
new file mode 100644
index 0000000..10c67e9
--- /dev/null
+++ b/src/engines/costs.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { computeCosts } from "./costs";
+import type { CostInput } from "./types";
+
+const base: CostInput = {
+ source: "GOVDEALS",
+ winningBid: 500,
+ quantity: 1,
+ weightLbs: 40,
+ condition: "USED_GOOD",
+ expectedSalePrice: 9000,
+ daysUntilSold: 30,
+};
+
+describe("computeCosts", () => {
+ it("applies the source buyer premium", () => {
+ const c = computeCosts(base);
+ // GovDeals premium is 10% of the winning bid.
+ expect(c.buyerPremium).toBeCloseTo(50, 2);
+ });
+
+ it("ships parcel under the freight threshold and freight above it", () => {
+ const light = computeCosts({ ...base, weightLbs: 40 });
+ expect(light.freight).toBe(0);
+ expect(light.shipping).toBeGreaterThan(0);
+
+ const heavy = computeCosts({ ...base, weightLbs: 600 });
+ expect(heavy.freight).toBeGreaterThan(0);
+ expect(heavy.shipping).toBe(0);
+ });
+
+ it("computes a positive net profit for a strong flip", () => {
+ const c = computeCosts(base);
+ expect(c.expectedNetProfit).toBeGreaterThan(0);
+ expect(c.roi).toBeGreaterThan(0);
+ expect(c.totalInvestment).toBeGreaterThan(base.winningBid);
+ });
+
+ it("recommends a max bid that back-solves to the target ROI", () => {
+ const c = computeCosts(base);
+ // Bidding exactly the recommended max should yield ~40% ROI.
+ const atMax = computeCosts({ ...base, winningBid: c.recommendedMaxBid });
+ expect(atMax.roi).toBeCloseTo(0.4, 1);
+ });
+
+ it("zeroes shipping/freight but adds pickup labor for local pickup", () => {
+ const c = computeCosts({ ...base, weightLbs: 600, localPickup: true });
+ expect(c.shipping).toBe(0);
+ expect(c.freight).toBe(0);
+ expect(c.pickupLabor).toBeGreaterThan(0);
+ });
+
+ it("applies purchase sales tax when a rate is provided", () => {
+ const taxed = computeCosts({ ...base, purchaseTaxRate: 0.0725 });
+ const untaxed = computeCosts({ ...base, purchaseTaxRate: 0 });
+ expect(taxed.salesTax).toBeGreaterThan(0);
+ expect(untaxed.salesTax).toBe(0);
+ expect(taxed.totalInvestment).toBeGreaterThan(untaxed.totalInvestment);
+ });
+});
diff --git a/src/engines/costs.ts b/src/engines/costs.ts
new file mode 100644
index 0000000..9b8ac7a
--- /dev/null
+++ b/src/engines/costs.ts
@@ -0,0 +1,162 @@
+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,
+ },
+ };
+}
diff --git a/src/engines/freight.ts b/src/engines/freight.ts
new file mode 100644
index 0000000..2541002
--- /dev/null
+++ b/src/engines/freight.ts
@@ -0,0 +1,47 @@
+import { round2 } from "@/lib/utils";
+import { FREIGHT_THRESHOLD_LBS } from "./constants";
+
+export interface FreightEstimate {
+ parcelShipping: number;
+ freight: number;
+ isFreight: boolean;
+ basis: string;
+}
+
+/**
+ * Estimate outbound logistics cost from total lot weight. Light items ship as
+ * parcel (USPS/UPS ground tiers); heavy items move as LTL freight with a base
+ * charge plus per-hundredweight (CWT) rate. Deliberately simple + explainable.
+ */
+export function estimateFreight(totalWeightLbs: number): FreightEstimate {
+ const w = Math.max(0, totalWeightLbs || 0);
+
+ if (w <= FREIGHT_THRESHOLD_LBS) {
+ // Parcel tiers.
+ let parcel: number;
+ if (w <= 1) parcel = 6;
+ else if (w <= 5) parcel = 12;
+ else if (w <= 20) parcel = 22;
+ else if (w <= 50) parcel = 45;
+ else if (w <= 100) parcel = 85;
+ else parcel = 130;
+ return {
+ parcelShipping: round2(parcel),
+ freight: 0,
+ isFreight: false,
+ basis: `parcel @ ${w} lb`,
+ };
+ }
+
+ // LTL freight: base handling + per-CWT (hundredweight) rate.
+ const base = 95;
+ const perCwt = 28;
+ const cwt = w / 100;
+ const freight = round2(base + perCwt * cwt);
+ return {
+ parcelShipping: 0,
+ freight,
+ isFreight: true,
+ basis: `LTL: $${base} base + $${perCwt}/cwt × ${cwt.toFixed(2)}cwt`,
+ };
+}
diff --git a/src/engines/scoring.test.ts b/src/engines/scoring.test.ts
new file mode 100644
index 0000000..acc3bbe
--- /dev/null
+++ b/src/engines/scoring.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { computeScores, computeComponents } from "./scoring";
+import type { ScoreInput } from "./scoring";
+
+const base: ScoreInput = {
+ roi: 0.6,
+ expectedNetProfit: 3000,
+ demandScore: 70,
+ daysUntilSold: 25,
+ weightLbs: 40,
+ condition: "USED_GOOD",
+ bidCount: 2,
+ localPickup: false,
+ hasBuyerLeads: false,
+ confidenceScore: 80,
+ probabilityOfSale: 0.7,
+ isFreight: false,
+};
+
+describe("computeScores", () => {
+ it("returns all nine scoring profiles", () => {
+ const scores = computeScores(base);
+ expect(scores).toHaveLength(9);
+ const profiles = scores.map((s) => s.profile).sort();
+ expect(profiles).toContain("OVERALL_OPPORTUNITY");
+ expect(profiles).toContain("BEST_ARBITRAGE");
+ expect(profiles).toContain("PARTS_ONLY");
+ });
+
+ it("every score is 0..100 and carries a non-empty explanation", () => {
+ for (const s of computeScores(base)) {
+ expect(s.value).toBeGreaterThanOrEqual(0);
+ expect(s.value).toBeLessThanOrEqual(100);
+ expect(s.explanation.length).toBeGreaterThan(20);
+ expect(s.factors.length).toBeGreaterThan(0);
+ }
+ });
+
+ it("rewards a genuine salvage lot under PARTS_ONLY and penalizes non-parts", () => {
+ const parts = computeScores({ ...base, condition: "FOR_PARTS" }).find(
+ (s) => s.profile === "PARTS_ONLY",
+ )!;
+ const notParts = computeScores({ ...base, condition: "NEW" }).find(
+ (s) => s.profile === "PARTS_ONLY",
+ )!;
+ expect(parts.value).toBeGreaterThan(notParts.value);
+ });
+
+ it("higher ROI raises the arbitrage component and BEST_ARBITRAGE score", () => {
+ const low = computeScores({ ...base, roi: 0.05 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+ const high = computeScores({ ...base, roi: 1.5 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+ expect(high.value).toBeGreaterThan(low.value);
+ });
+
+ it("flags HIGH risk on low confidence + thin margin", () => {
+ const risky = computeScores({
+ ...base,
+ confidenceScore: 30,
+ roi: 0.05,
+ condition: "UNKNOWN",
+ daysUntilSold: 100,
+ })[0];
+ expect(risky.risk).toBe("HIGH");
+ });
+
+ it("marks light non-pickup items as EASY drop-ship, heavy freight as harder", () => {
+ const easy = computeComponents(base);
+ expect(easy.logistics).toBeGreaterThan(50);
+ const heavy = computeScores({ ...base, weightLbs: 700, isFreight: true })[0];
+ expect(["DIFFICULT", "INFEASIBLE", "MODERATE"]).toContain(heavy.dropShip);
+ });
+});
diff --git a/src/engines/scoring.ts b/src/engines/scoring.ts
new file mode 100644
index 0000000..8f45118
--- /dev/null
+++ b/src/engines/scoring.ts
@@ -0,0 +1,196 @@
+import { clamp, round2, formatPercent, formatMoney } from "@/lib/utils";
+import type {
+ ConditionKey,
+ DropShipKey,
+ ProfileScore,
+ RiskKey,
+ ScoreComponents,
+ ScoreProfileKey,
+} from "./types";
+
+export interface ScoreInput {
+ roi: number;
+ expectedNetProfit: number;
+ demandScore: number; // 0..100
+ daysUntilSold: number;
+ weightLbs: number;
+ condition: ConditionKey;
+ bidCount: number;
+ localPickup: boolean;
+ hasBuyerLeads: boolean;
+ confidenceScore: number; // 0..100
+ probabilityOfSale: number; // 0..1
+ isFreight: boolean;
+}
+
+const CONDITION_SCORE: Record<ConditionKey, number> = {
+ NEW: 95,
+ LIKE_NEW: 82,
+ USED_GOOD: 65,
+ USED_FAIR: 45,
+ FOR_PARTS: 22,
+ UNKNOWN: 40,
+};
+
+/** Map an ROI ratio onto a 0..100 arbitrage score with diminishing returns. */
+function arbitrageFromRoi(roi: number): number {
+ // roi -0.5 -> ~5, 0 -> 35, 0.4 -> 70, 1.0 -> 88, 2.0+ -> ~98
+ const s = 35 + 55 * (1 - Math.exp(-1.15 * Math.max(0, roi))) + (roi < 0 ? roi * 60 : 0);
+ return clamp(s, 0, 100);
+}
+
+/** Compute the seven component sub-scores. */
+export function computeComponents(input: ScoreInput): ScoreComponents {
+ const arbitrage = round2(arbitrageFromRoi(input.roi));
+ const demand = round2(clamp(input.demandScore, 0, 100));
+ const velocity = round2(clamp(100 - input.daysUntilSold * 0.72, 0, 100));
+
+ // Logistics: lighter + parcel-shippable is best; freight and heavy items hurt.
+ let logistics = 100 - Math.min(input.weightLbs, 1000) * 0.06;
+ if (input.isFreight) logistics -= 25;
+ if (input.localPickup) logistics -= 10; // pickup adds friction unless local buyer
+ logistics = round2(clamp(logistics, 0, 100));
+
+ const condition = CONDITION_SCORE[input.condition] ?? 40;
+ const competition = round2(clamp(95 - input.bidCount * 6, 5, 100));
+ const buyer = round2(
+ clamp(input.probabilityOfSale * 70 + (input.hasBuyerLeads ? 25 : 0) + input.demandScore * 0.1, 0, 100),
+ );
+
+ return { arbitrage, demand, velocity, logistics, condition, competition, buyer };
+}
+
+export function computeRisk(input: ScoreInput, c: ScoreComponents): RiskKey {
+ let risk = 0;
+ if (input.confidenceScore < 45) risk += 2;
+ else if (input.confidenceScore < 70) risk += 1;
+ if (input.roi < 0.15) risk += 2;
+ else if (input.roi < 0.35) risk += 1;
+ if (input.condition === "FOR_PARTS" || input.condition === "UNKNOWN") risk += 1;
+ if (c.velocity < 40) risk += 1;
+ if (risk >= 4) return "HIGH";
+ if (risk >= 2) return "MEDIUM";
+ return "LOW";
+}
+
+export function computeDropShip(input: ScoreInput): DropShipKey {
+ // Drop-ship feasibility: can this move seller -> buyer without you touching it?
+ if (input.localPickup && input.weightLbs > 300) return "INFEASIBLE";
+ if (input.isFreight) return input.weightLbs > 500 ? "DIFFICULT" : "MODERATE";
+ if (input.weightLbs <= 50 && !input.localPickup) return "EASY";
+ if (input.weightLbs <= 150) return "MODERATE";
+ return "DIFFICULT";
+}
+
+// Per-profile component weights. Each row sums to ~1 across the 7 components.
+const PROFILE_WEIGHTS: Record<
+ ScoreProfileKey,
+ Partial<ScoreComponents> & { _confidence?: number }
+> = {
+ OVERALL_OPPORTUNITY: { arbitrage: 0.28, demand: 0.18, velocity: 0.14, logistics: 0.12, condition: 0.1, competition: 0.1, buyer: 0.08 },
+ BEST_ARBITRAGE: { arbitrage: 0.5, demand: 0.2, competition: 0.15, condition: 0.15 },
+ QUICK_FLIP: { velocity: 0.4, demand: 0.25, logistics: 0.2, arbitrage: 0.15 },
+ COLLECTOR: { demand: 0.4, condition: 0.35, arbitrage: 0.25 },
+ LOCAL_PICKUP: { arbitrage: 0.4, demand: 0.25, condition: 0.2, competition: 0.15 },
+ EASY_FREIGHT: { logistics: 0.45, arbitrage: 0.3, demand: 0.25 },
+ PARTS_ONLY: { arbitrage: 0.55, demand: 0.25, competition: 0.2 },
+ HIGH_CONFIDENCE: { arbitrage: 0.3, demand: 0.2, condition: 0.2, buyer: 0.15, velocity: 0.15 },
+ HIGH_PROFIT: { arbitrage: 0.45, demand: 0.3, velocity: 0.15, logistics: 0.1 },
+};
+
+const PROFILE_LABEL: Record<ScoreProfileKey, string> = {
+ OVERALL_OPPORTUNITY: "Overall Opportunity",
+ BEST_ARBITRAGE: "Best Arbitrage",
+ QUICK_FLIP: "Quick Flip",
+ COLLECTOR: "Collector",
+ LOCAL_PICKUP: "Local Pickup",
+ EASY_FREIGHT: "Easy Freight",
+ PARTS_ONLY: "Parts Only",
+ HIGH_CONFIDENCE: "High Confidence",
+ HIGH_PROFIT: "High Profit",
+};
+
+const COMPONENT_LABEL: Record<keyof ScoreComponents, string> = {
+ arbitrage: "arbitrage margin",
+ demand: "market demand",
+ velocity: "sale velocity",
+ logistics: "logistics ease",
+ condition: "item condition",
+ competition: "low competition",
+ buyer: "buyer readiness",
+};
+
+function scoreProfile(
+ profile: ScoreProfileKey,
+ c: ScoreComponents,
+ input: ScoreInput,
+ risk: RiskKey,
+ dropShip: DropShipKey,
+): ProfileScore {
+ const weights = PROFILE_WEIGHTS[profile];
+ const factors: ProfileScore["factors"] = [];
+ let value = 0;
+
+ for (const [key, w] of Object.entries(weights) as [keyof ScoreComponents, number][]) {
+ if (key === ("_confidence" as keyof ScoreComponents)) continue;
+ const comp = c[key] ?? 0;
+ const contribution = round2(comp * w);
+ value += contribution;
+ factors.push({ label: COMPONENT_LABEL[key], weight: w, contribution });
+ }
+
+ // Profile-specific adjustments beyond the weighted base.
+ let adjustment = 0;
+ const notes: string[] = [];
+ if (profile === "HIGH_CONFIDENCE") {
+ adjustment = (input.confidenceScore - 50) * 0.3;
+ notes.push(`confidence ${input.confidenceScore.toFixed(0)}/100`);
+ }
+ if (profile === "PARTS_ONLY" && input.condition !== "FOR_PARTS") {
+ adjustment -= 20; // parts-only profile penalizes non-parts items
+ notes.push("not a parts/salvage lot");
+ }
+ if (profile === "PARTS_ONLY" && input.condition === "FOR_PARTS") {
+ adjustment += 12;
+ notes.push("genuine salvage lot");
+ }
+ if (profile === "LOCAL_PICKUP") {
+ adjustment += input.localPickup ? 8 : -12;
+ notes.push(input.localPickup ? "local pickup available" : "no local pickup");
+ }
+ if (profile === "EASY_FREIGHT" && input.isFreight) {
+ adjustment -= 15;
+ notes.push("ships as LTL freight");
+ }
+ if (profile === "HIGH_PROFIT") {
+ // Reward large absolute dollars, not just ratio.
+ adjustment += clamp(input.expectedNetProfit / 250, -10, 20);
+ notes.push(`${formatMoney(input.expectedNetProfit)} est. net`);
+ }
+
+ value = round2(clamp(value + adjustment, 0, 100));
+
+ // Build the "why" explanation from the top contributors.
+ const top = [...factors].sort((a, b) => b.contribution - a.contribution).slice(0, 3);
+ const drivers = top
+ .map((f) => `${f.label} (${f.contribution.toFixed(0)} pts)`)
+ .join(", ");
+ const roiPhrase = `ROI ${formatPercent(input.roi)}`;
+ const extra = notes.length ? ` Adjustments: ${notes.join("; ")}.` : "";
+ const explanation =
+ `${PROFILE_LABEL[profile]} scored ${value.toFixed(0)}/100. ` +
+ `Top drivers: ${drivers}. ${roiPhrase}, ${input.daysUntilSold}d to sell, ` +
+ `risk ${risk}, drop-ship ${dropShip}.${extra}`;
+
+ return { profile, value, components: c, risk, dropShip, explanation, factors };
+}
+
+/** Compute all nine profile scores for a listing. */
+export function computeScores(input: ScoreInput): ProfileScore[] {
+ const c = computeComponents(input);
+ const risk = computeRisk(input, c);
+ const dropShip = computeDropShip(input);
+ return (Object.keys(PROFILE_WEIGHTS) as ScoreProfileKey[]).map((p) =>
+ scoreProfile(p, c, input, risk, dropShip),
+ );
+}
diff --git a/src/engines/types.ts b/src/engines/types.ts
new file mode 100644
index 0000000..e9b95c5
--- /dev/null
+++ b/src/engines/types.ts
@@ -0,0 +1,133 @@
+// Shared engine types. Engines operate on plain numbers (US dollars) so they
+// are pure, deterministic, and unit-testable independent of Prisma/Decimal.
+
+export type ConditionKey =
+ | "NEW"
+ | "LIKE_NEW"
+ | "USED_GOOD"
+ | "USED_FAIR"
+ | "FOR_PARTS"
+ | "UNKNOWN";
+
+export type SourceKey =
+ | "GOVDEALS"
+ | "PUBLIC_SURPLUS"
+ | "GSA_AUCTIONS"
+ | "COUNTY"
+ | "STATE_SURPLUS"
+ | "UNIVERSITY_SURPLUS"
+ | "MUNICIBID"
+ | "BID4ASSETS"
+ | "CSV"
+ | "EXTENSION"
+ | "OTHER";
+
+export type RiskKey = "LOW" | "MEDIUM" | "HIGH";
+export type DropShipKey = "EASY" | "MODERATE" | "DIFFICULT" | "INFEASIBLE";
+
+export type ScoreProfileKey =
+ | "OVERALL_OPPORTUNITY"
+ | "BEST_ARBITRAGE"
+ | "QUICK_FLIP"
+ | "COLLECTOR"
+ | "LOCAL_PICKUP"
+ | "EASY_FREIGHT"
+ | "PARTS_ONLY"
+ | "HIGH_CONFIDENCE"
+ | "HIGH_PROFIT";
+
+/** Inputs the valuation engine needs (anchor + item facts). */
+export interface ValuationInput {
+ /** Best estimate of new retail price; the anchor for all derived values. */
+ newRetail: number;
+ condition: ConditionKey;
+ quantity: number;
+ /** 0..100 demand for this category/brand (from research/AI or a default). */
+ demandScore: number;
+ /** How well the item was identified (0..1); drives confidence. */
+ identificationConfidence: number;
+ /** Count of real comparable sales found; drives confidence. */
+ comparableCount?: number;
+}
+
+export interface Valuation {
+ newRetail: number;
+ newReplacement: number;
+ avgRetail: number;
+ usedSoldPrice: number;
+ usedAskingPrice: number;
+ usedLow: number;
+ usedHigh: number;
+ wholesaleValue: number;
+ liquidationValue: number;
+ sellTodayValue: number;
+ value7Day: number;
+ value30Day: number;
+ value90Day: number;
+ expectedSalePrice: number;
+ probabilityOfSale: number; // 0..1
+ daysUntilSold: number;
+ confidenceScore: number; // 0..100
+}
+
+export interface CostInput {
+ source: SourceKey;
+ winningBid: number;
+ quantity: number;
+ weightLbs: number;
+ condition: ConditionKey;
+ expectedSalePrice: number;
+ daysUntilSold: number;
+ /** true when the item must be picked up in person (no shipping from seller). */
+ localPickup?: boolean;
+ /** destination sales-tax rate applied to the purchase (resale certs may zero this). */
+ purchaseTaxRate?: number;
+ /** repair estimate override (else derived from condition). */
+ repairsOverride?: number;
+}
+
+export interface CostBreakdown {
+ winningBid: number;
+ buyerPremium: number;
+ salesTax: number;
+ shipping: number;
+ freight: number;
+ insurance: number;
+ packing: number;
+ pickupLabor: number;
+ testing: number;
+ repairs: number;
+ certification: number;
+ marketplaceFees: number;
+ paymentFees: number;
+ storage: number;
+ photography: number;
+ listingLabor: number;
+ expectedReturns: number;
+ totalInvestment: number;
+ expectedNetProfit: number;
+ roi: number;
+ annualizedReturn: number;
+ recommendedMaxBid: number;
+ assumptions: Record<string, string | number>;
+}
+
+export interface ScoreComponents {
+ arbitrage: number;
+ demand: number;
+ velocity: number;
+ logistics: number;
+ condition: number;
+ competition: number;
+ buyer: number;
+}
+
+export interface ProfileScore {
+ profile: ScoreProfileKey;
+ value: number;
+ components: ScoreComponents;
+ risk: RiskKey;
+ dropShip: DropShipKey;
+ explanation: string;
+ factors: { label: string; weight: number; contribution: number }[];
+}
diff --git a/src/engines/valuation.test.ts b/src/engines/valuation.test.ts
new file mode 100644
index 0000000..a8af7ee
--- /dev/null
+++ b/src/engines/valuation.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { computeValuation } from "./valuation";
+
+describe("computeValuation", () => {
+ it("derives a full valuation ladder from a retail anchor", () => {
+ const v = computeValuation({
+ newRetail: 20000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 70,
+ identificationConfidence: 0.9,
+ comparableCount: 6,
+ });
+
+ // Used-good sits at ~46% of retail.
+ expect(v.usedSoldPrice).toBeCloseTo(9200, 0);
+ expect(v.usedAskingPrice).toBeGreaterThan(v.usedSoldPrice);
+ expect(v.usedLow).toBeLessThan(v.usedSoldPrice);
+ expect(v.usedHigh).toBeGreaterThan(v.usedSoldPrice);
+ // Channel ordering: liquidation < wholesale < used.
+ expect(v.liquidationValue).toBeLessThan(v.wholesaleValue);
+ expect(v.wholesaleValue).toBeLessThan(v.usedSoldPrice);
+ // Longer horizon fetches more than a fire sale.
+ expect(v.value90Day).toBeGreaterThan(v.sellTodayValue);
+ expect(v.expectedSalePrice).toBeGreaterThan(0);
+ });
+
+ it("scales expected sale price by quantity", () => {
+ const one = computeValuation({
+ newRetail: 500,
+ condition: "LIKE_NEW",
+ quantity: 1,
+ demandScore: 50,
+ identificationConfidence: 0.6,
+ });
+ const ten = computeValuation({
+ newRetail: 500,
+ condition: "LIKE_NEW",
+ quantity: 10,
+ demandScore: 50,
+ identificationConfidence: 0.6,
+ });
+ expect(ten.expectedSalePrice).toBeCloseTo(one.expectedSalePrice * 10, 0);
+ });
+
+ it("gives higher confidence with more comparables and better ID", () => {
+ const weak = computeValuation({
+ newRetail: 1000,
+ condition: "UNKNOWN",
+ quantity: 1,
+ demandScore: 40,
+ identificationConfidence: 0.2,
+ comparableCount: 0,
+ });
+ const strong = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 40,
+ identificationConfidence: 0.95,
+ comparableCount: 8,
+ });
+ expect(strong.confidenceScore).toBeGreaterThan(weak.confidenceScore);
+ expect(strong.confidenceScore).toBeLessThanOrEqual(100);
+ });
+
+ it("rewards high demand with faster sale and higher probability", () => {
+ const lowDemand = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 10,
+ identificationConfidence: 0.7,
+ });
+ const highDemand = computeValuation({
+ newRetail: 1000,
+ condition: "USED_GOOD",
+ quantity: 1,
+ demandScore: 95,
+ identificationConfidence: 0.7,
+ });
+ expect(highDemand.daysUntilSold).toBeLessThan(lowDemand.daysUntilSold);
+ expect(highDemand.probabilityOfSale).toBeGreaterThan(lowDemand.probabilityOfSale);
+ });
+});
diff --git a/src/engines/valuation.ts b/src/engines/valuation.ts
new file mode 100644
index 0000000..1f80eaf
--- /dev/null
+++ b/src/engines/valuation.ts
@@ -0,0 +1,84 @@
+import { clamp, round2 } from "@/lib/utils";
+import { CONDITION_RETAIL_FACTOR } from "./constants";
+import type { Valuation, ValuationInput } from "./types";
+
+/**
+ * Derive the full valuation ladder from a single retail anchor plus item facts.
+ *
+ * The AI/research layer supplies the hard-to-know inputs (a `newRetail` anchor,
+ * a `demandScore`, identification confidence, comparable count); everything
+ * else is derived deterministically here so the numbers are reproducible and
+ * explainable rather than a black box.
+ */
+export function computeValuation(input: ValuationInput): Valuation {
+ const qty = Math.max(1, input.quantity || 1);
+ const perUnitRetail = Math.max(0, input.newRetail);
+ const condFactor = CONDITION_RETAIL_FACTOR[input.condition] ?? 0.35;
+ const demand = clamp(input.demandScore ?? 50, 0, 100);
+
+ // Per-unit retail figures.
+ const newRetail = perUnitRetail;
+ const newReplacement = round2(perUnitRetail * 1.05); // like-for-like buy-new cost
+ const avgRetail = round2(perUnitRetail * 0.92); // street average vs MSRP
+
+ // Used market, anchored off condition.
+ const usedSoldPrice = round2(perUnitRetail * condFactor);
+ const usedAskingPrice = round2(usedSoldPrice * 1.25);
+ const usedLow = round2(usedSoldPrice * 0.8);
+ const usedHigh = round2(usedSoldPrice * 1.3);
+
+ // Channel values (per unit).
+ const wholesaleValue = round2(usedSoldPrice * 0.6);
+ const liquidationValue = round2(usedSoldPrice * 0.42);
+ const sellTodayValue = round2(liquidationValue * 1.1); // fire-sale, today
+
+ // Time-horizon values: the longer you wait, the closer to asking you get,
+ // scaled by how much demand there is.
+ const patience = 0.5 + demand / 200; // 0.5..1.0
+ const value7Day = round2(usedSoldPrice * (0.8 + 0.1 * patience));
+ const value30Day = round2(usedSoldPrice * (0.95 + 0.1 * patience));
+ const value90Day = round2(usedAskingPrice * (0.85 + 0.1 * patience));
+
+ // Expected sale price: demand-weighted blend across horizons.
+ const expectedSalePrice = round2(
+ value7Day * 0.2 + value30Day * 0.5 + value90Day * 0.3,
+ );
+
+ // Probability of sale within 90 days, driven by demand + condition quality.
+ const probabilityOfSale = round2(
+ clamp(0.35 + demand / 200 + (condFactor - 0.35) * 0.4, 0.1, 0.98),
+ );
+
+ // Days until sold: high demand sells fast; low demand lingers.
+ const daysUntilSold = Math.round(clamp(90 - demand * 0.75, 5, 120));
+
+ // Confidence: how much do we trust these numbers? Built from identification
+ // confidence and the number of real comparables backing the estimate.
+ const idConf = clamp(input.identificationConfidence ?? 0.5, 0, 1);
+ const comps = input.comparableCount ?? 0;
+ const confidenceScore = round2(
+ clamp(idConf * 60 + Math.min(comps, 8) * 5, 0, 100),
+ );
+
+ // Return per-unit valuations but scale the aggregate resale-relevant figure
+ // (expectedSalePrice) by quantity, since a lot of N sells N units.
+ return {
+ newRetail,
+ newReplacement,
+ avgRetail,
+ usedSoldPrice,
+ usedAskingPrice,
+ usedLow,
+ usedHigh,
+ wholesaleValue,
+ liquidationValue,
+ sellTodayValue,
+ value7Day,
+ value30Day,
+ value90Day,
+ expectedSalePrice: round2(expectedSalePrice * qty),
+ probabilityOfSale,
+ daysUntilSold,
+ confidenceScore,
+ };
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..84941e8
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+
+// Reuse a single PrismaClient across hot-reloads in dev to avoid exhausting
+// Postgres connections (Next.js re-evaluates modules on every change).
+const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
+
+export const prisma =
+ globalForPrisma.prisma ??
+ new PrismaClient({
+ log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
+ });
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d39b59b
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,50 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/** shadcn/ui-style className combiner. */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/** Round to whole cents (avoids float drift; all engine money is in dollars). */
+export function round2(n: number): number {
+ return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
+export function clamp(n: number, lo: number, hi: number): number {
+ return Math.min(hi, Math.max(lo, n));
+}
+
+const usd = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+});
+const usdCents = new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+});
+
+export function formatMoney(n: number | null | undefined, cents = false): string {
+ if (n === null || n === undefined || Number.isNaN(n)) return "—";
+ return cents ? usdCents.format(n) : usd.format(n);
+}
+
+export function formatPercent(ratio: number | null | undefined, digits = 0): string {
+ if (ratio === null || ratio === undefined || Number.isNaN(ratio)) return "—";
+ return `${(ratio * 100).toFixed(digits)}%`;
+}
+
+/** Human countdown to a future date, e.g. "2d 4h" or "Closed". */
+export function countdown(to: Date | string | null | undefined, now = new Date()): string {
+ if (!to) return "—";
+ const target = typeof to === "string" ? new Date(to) : to;
+ const ms = target.getTime() - now.getTime();
+ if (ms <= 0) return "Closed";
+ const d = Math.floor(ms / 86_400_000);
+ const h = Math.floor((ms % 86_400_000) / 3_600_000);
+ const m = Math.floor((ms % 3_600_000) / 60_000);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..3dbf34f
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "node:url";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts", "tests/unit/**/*.test.ts"],
+ globals: true,
+ },
+});
← d537d06 Phase 1: Next.js 16 + TS + Prisma 6 + Postgres scaffold, ful
·
back to Govarbitrage
·
Phase 3: local-Ollama AI identification, research pipeline, 23bcc51 →