@archerjessop/utilities 7.32.0 → 7.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- import{FINANCIAL_CONSTANTS as e}from"../config/financial.js";import{calculateBalloonBalance as n,calculatePMT as t}from"./calculations.js";const a={EBITDA_ADVANCE_RATE:.2,EBITDA_ADVANCE_THRESHOLD:1e6,FF_E_ADVANCE_RATE:.3,INVENTORY_ADVANCE_RATE:.2,OFFER_MULTIPLE_HIGH:3,OFFER_MULTIPLE_LOW:2,REAL_ESTATE_ADVANCE_RATE:.5};function toNumber(e){if(null==e||""===e)return null;const n="number"==typeof e?e:parseFloat(String(e).replace(/[$,\s]/g,""));return Number.isFinite(n)?n:null}function toNonNegative(e){const n=toNumber(e);return null===n||n<0?null:n}function resolveBusinessEarnings(e={}){const n=toNumber(e.ebitda);if(null!==n)return{earnings:n,source:"ebitda"};const t=toNumber(e.sde);if(null!==t)return{earnings:t,source:"sde"};const a=toNumber(e.cashFlow??e.cash_flow);return null!==a?{earnings:a,source:"cash_flow"}:{earnings:null,source:null}}function shouldIncludeRealEstate(e={}){if("boolean"==typeof e.statedInclusion)return e.statedInclusion;const n=toNumber(e.askingPrice),t=toNumber(e.realEstateValue);return!(null!==n&&n>0&&null!==t&&t>=n)}function calculateBusinessDownPayment(e={}){const{EBITDA_ADVANCE_RATE:n,EBITDA_ADVANCE_THRESHOLD:t,FF_E_ADVANCE_RATE:l,INVENTORY_ADVANCE_RATE:r,REAL_ESTATE_ADVANCE_RATE:s}=a,{includeEbitda:o=!0,includeFfe:i=!0,includeInventory:u=!0,includeRealEstate:c=!0}=e,f=c?toNonNegative(e.realEstateValue)??0:0,d=i?toNonNegative(e.ffeValue)??0:0,E=u?toNonNegative(e.inventoryValue)??0:0,_=o?toNumber(e.ebitda)??0:0,N={ebitda:_>=t?_*n:0,ffe:d*l,inventory:E*r,realEstate:f*s};return{downPayment:N.realEstate+N.ffe+N.inventory+N.ebitda,legs:N}}function calculateBusinessOffer(e={}){const{OFFER_MULTIPLE_HIGH:n,OFFER_MULTIPLE_LOW:t}=a,{includeFfe:l=!0,includeInventory:r=!0,includeRealEstate:s=!0}=e,o=(s?toNonNegative(e.realEstateValue)??0:0)+(l?toNonNegative(e.ffeValue)??0:0)+(r?toNonNegative(e.inventoryValue)??0:0),i=toNumber(e.earnings);return null===i?{assetsIncluded:o,offerHigh:null,offerLow:null}:{assetsIncluded:o,offerHigh:i*n+o,offerLow:i*t+o}}function calculateBusinessSellerFinance(a={}){const{amortizationYears:l=e.SELLER_FI_AMORTIZATION,balloonYears:r=e.DEFAULT_BALLOON_PERIOD_YEARS,interestPaymentMode:s="standard",interestRate:o=e.SELLER_FI_INTEREST_RATE}=a,i=toNumber(a.priceOffered);if(null===i)return{balloonBalance:null,performancePayout:0,sellerFinanced:null,sfPayment:null,totalPayments:null};const u=toNonNegative(a.downPayment)??0,c=Math.max(0,i-u);let f,d=0;"standard"===s?f=t(c,o,l):(f=t(c,0,l),"simple_payout"===s?d=c*o*r:"compound_payout"===s&&(d=c*(Math.pow(1+o,r)-1)));return{balloonBalance:("standard"===s?n(c,o,l,r):c)+d,performancePayout:d,sellerFinanced:c,sfPayment:f,totalPayments:12*f*r}}function underwriteBusinessListing(e={}){const n=e.realEstateValue??e.real_estate_value,t=e.ffeValue??e.ff_e_value,a=e.inventoryValue??e.inventory_value,l=e.ebitda,{earnings:r,source:s}=resolveBusinessEarnings({cashFlow:e.cashFlow??e.cash_flow,ebitda:l,sde:e.sde}),o=e.includeEbitda??e.include_ebitda??!0,i=e.includeFfe??e.include_ff_e??!0,u=e.includeInventory??e.include_inventory??!0,c=e.includeRealEstate??e.include_real_estate??!0,{downPayment:f,legs:d}=calculateBusinessDownPayment({ebitda:l,ffeValue:t,includeEbitda:o,includeFfe:i,includeInventory:u,includeRealEstate:c,inventoryValue:a,realEstateValue:n}),{assetsIncluded:E,offerHigh:_,offerLow:N}=calculateBusinessOffer({earnings:r,ffeValue:t,includeFfe:i,includeInventory:u,includeRealEstate:c,inventoryValue:a,realEstateValue:n}),m=calculateBusinessSellerFinance({amortizationYears:e.sellerAmortization??e.seller_amortization??void 0,balloonYears:e.balloonLength??e.balloon_length??void 0,downPayment:f,interestPaymentMode:e.interestPaymentMode??e.interest_payment_mode??"standard",interestRate:e.sellerFiRate??e.seller_fi_rate??void 0,priceOffered:e.priceOffered??e.price_offered});return{assetsIncluded:E,collateralShortfall:null!==_&&f>_,downPayment:f,earnings:r,earningsSource:e.earningsSource??e.earnings_source??s,legs:d,offerHigh:_,offerLow:N,priceOffered:toNumber(e.priceOffered??e.price_offered),sellerCarry:null===_?null:_-f,terms:m}}export{a as BUSINESS_UNDERWRITING_CONSTANTS,calculateBusinessDownPayment,calculateBusinessOffer,calculateBusinessSellerFinance,resolveBusinessEarnings,shouldIncludeRealEstate,underwriteBusinessListing};
1
+ import{FINANCIAL_CONSTANTS as e}from"../config/financial.js";import{calculateBalloonBalance as n,calculatePMT as t}from"./calculations.js";const a={EBITDA_ADVANCE_RATE:.2,EBITDA_ADVANCE_THRESHOLD:1e6,FF_E_ADVANCE_RATE:.3,INVENTORY_ADVANCE_RATE:.2,OFFER_MULTIPLE_HIGH:3,OFFER_MULTIPLE_LOW:2,REAL_ESTATE_ADVANCE_RATE:.5};function toNumber(e){if(null==e||""===e)return null;const n="number"==typeof e?e:parseFloat(String(e).replace(/[$,\s]/g,""));return Number.isFinite(n)?n:null}function toNonNegative(e){const n=toNumber(e);return null===n||n<0?null:n}function resolveBusinessEarnings(e={}){const n=toNumber(e.ebitda);if(null!==n)return{earnings:n,source:"ebitda"};const t=toNumber(e.sde);if(null!==t)return{earnings:t,source:"sde"};const a=toNumber(e.cashFlow??e.cash_flow);return null!==a?{earnings:a,source:"cash_flow"}:{earnings:null,source:null}}function shouldIncludeRealEstate(e={}){if("boolean"==typeof e.statedInclusion)return e.statedInclusion;const n=toNumber(e.askingPrice),t=toNumber(e.realEstateValue);return!(null!==n&&n>0&&null!==t&&t>=n)}function calculateBusinessDownPayment(e={}){const{EBITDA_ADVANCE_RATE:n,EBITDA_ADVANCE_THRESHOLD:t,FF_E_ADVANCE_RATE:l,INVENTORY_ADVANCE_RATE:r,REAL_ESTATE_ADVANCE_RATE:s}=a,{collateralizeEbitda:o=!0}=e,i=toNonNegative(e.realEstateValue)??0,u=toNonNegative(e.ffeValue)??0,c=toNonNegative(e.inventoryValue)??0,f=o?toNumber(e.ebitda)??0:0,d={ebitda:f>=t?f*n:0,ffe:u*l,inventory:c*r,realEstate:i*s};return{downPayment:d.realEstate+d.ffe+d.inventory+d.ebitda,legs:d}}function calculateBusinessOffer(e={}){const{OFFER_MULTIPLE_HIGH:n,OFFER_MULTIPLE_LOW:t}=a,{includeFfe:l=!0,includeInventory:r=!0,includeRealEstate:s=!0}=e,o=(s?toNonNegative(e.realEstateValue)??0:0)+(l?toNonNegative(e.ffeValue)??0:0)+(r?toNonNegative(e.inventoryValue)??0:0),i=toNumber(e.earnings);return null===i?{assetsIncluded:o,offerHigh:null,offerLow:null}:{assetsIncluded:o,offerHigh:i*n+o,offerLow:i*t+o}}function calculateBusinessSellerFinance(a={}){const{amortizationYears:l=e.SELLER_FI_AMORTIZATION,balloonYears:r=e.DEFAULT_BALLOON_PERIOD_YEARS,interestPaymentMode:s="standard",interestRate:o=e.SELLER_FI_INTEREST_RATE}=a,i=toNumber(a.priceOffered);if(null===i)return{balloonBalance:null,performancePayout:0,sellerFinanced:null,sfPayment:null,totalPayments:null};const u=toNonNegative(a.downPayment)??0,c=Math.max(0,i-u);let f,d=0;"standard"===s?f=t(c,o,l):(f=t(c,0,l),"simple_payout"===s?d=c*o*r:"compound_payout"===s&&(d=c*(Math.pow(1+o,r)-1)));return{balloonBalance:("standard"===s?n(c,o,l,r):c)+d,performancePayout:d,sellerFinanced:c,sfPayment:f,totalPayments:12*f*r}}function underwriteBusinessListing(e={}){const n=e.realEstateValue??e.real_estate_value,t=e.ffeValue??e.ff_e_value,a=e.inventoryValue??e.inventory_value,l=e.ebitda,{earnings:r,source:s}=resolveBusinessEarnings({cashFlow:e.cashFlow??e.cash_flow,ebitda:l,sde:e.sde}),{downPayment:o,legs:i}=calculateBusinessDownPayment({collateralizeEbitda:e.collateralizeEbitda??e.collateralize_ebitda??!0,ebitda:l,ffeValue:t,inventoryValue:a,realEstateValue:n}),{assetsIncluded:u,offerHigh:c,offerLow:f}=calculateBusinessOffer({earnings:r,ffeValue:t,includeFfe:e.includeFfe??e.include_ff_e??!0,includeInventory:e.includeInventory??e.include_inventory??!0,includeRealEstate:e.includeRealEstate??e.include_real_estate??!0,inventoryValue:a,realEstateValue:n}),d=calculateBusinessSellerFinance({amortizationYears:e.sellerAmortization??e.seller_amortization??void 0,balloonYears:e.balloonLength??e.balloon_length??void 0,downPayment:o,interestPaymentMode:e.interestPaymentMode??e.interest_payment_mode??"standard",interestRate:e.sellerFiRate??e.seller_fi_rate??void 0,priceOffered:e.priceOffered??e.price_offered});return{assetsIncluded:u,collateralShortfall:null!==c&&o>c,downPayment:o,earnings:r,earningsSource:e.earningsSource??e.earnings_source??s,legs:i,offerHigh:c,offerLow:f,priceOffered:toNumber(e.priceOffered??e.price_offered),sellerCarry:null===c?null:c-o,terms:d}}export{a as BUSINESS_UNDERWRITING_CONSTANTS,calculateBusinessDownPayment,calculateBusinessOffer,calculateBusinessSellerFinance,resolveBusinessEarnings,shouldIncludeRealEstate,underwriteBusinessListing};
2
2
  //# sourceMappingURL=business-underwriting.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"business-underwriting.js","sources":["../../src/financial/business-underwriting.js"],"sourcesContent":["// src/financial/business-underwriting.js\n//\n// Underwriting for businesses backed by real estate (the BizBuySell pipeline).\n// The real-estate model prices a property off NOI and a cap rate; this one\n// prices a business off an earnings multiple and adds the tangible assets that\n// convey with it. Shared by the BizBuySell extension panel and the dashboard so\n// both quote the same numbers from the same inputs.\n\nimport { FINANCIAL_CONSTANTS } from \"../config/financial.js\";\nimport { calculateBalloonBalance, calculatePMT } from \"./calculations.js\";\n\n/**\n * Collateral advance rates for the down payment, and the earnings multiples\n * bounding the offer. The EBITDA leg only contributes at or above its threshold\n * — smaller earnings are not treated as collateral.\n */\nexport const BUSINESS_UNDERWRITING_CONSTANTS = {\n EBITDA_ADVANCE_RATE: 0.20,\n EBITDA_ADVANCE_THRESHOLD: 1000000,\n FF_E_ADVANCE_RATE: 0.30,\n INVENTORY_ADVANCE_RATE: 0.20,\n OFFER_MULTIPLE_HIGH: 3,\n OFFER_MULTIPLE_LOW: 2,\n REAL_ESTATE_ADVANCE_RATE: 0.50,\n};\n\n// Coerce a scraped/stored figure to a finite number, else null. Absent data must\n// stay absent — a missing earnings figure means \"no offer\", never \"an offer of 0\".\nfunction toNumber(value) {\n if (value === null || value === undefined || value === \"\") return null;\n const numeric = typeof value === \"number\" ? value : parseFloat(String(value).replace(/[$,\\s]/g, \"\"));\n return Number.isFinite(numeric) ? numeric : null;\n}\n\nfunction toNonNegative(value) {\n const numeric = toNumber(value);\n return numeric === null || numeric < 0 ? null : numeric;\n}\n\n/**\n * Pick the earnings figure that drives the offer multiple, preferring EBITDA and\n * falling back to SDE then cash flow — most BizBuySell listings publish only\n * \"Cash Flow (SDE)\". The source is returned alongside so callers can label which\n * figure the multiple was applied to instead of implying it was EBITDA.\n * @param {{ebitda?:number, sde?:number, cashFlow?:number}} listing\n * @returns {{earnings:number|null, source:string|null}}\n */\nexport function resolveBusinessEarnings(listing = {}) {\n const ebitda = toNumber(listing.ebitda);\n if (ebitda !== null) return { earnings: ebitda, source: \"ebitda\" };\n\n const sde = toNumber(listing.sde);\n if (sde !== null) return { earnings: sde, source: \"sde\" };\n\n const cashFlow = toNumber(listing.cashFlow ?? listing.cash_flow);\n if (cashFlow !== null) return { earnings: cashFlow, source: \"cash_flow\" };\n\n return { earnings: null, source: null };\n}\n\n/**\n * Whether the real estate should be ADDED to the offer on top of the earnings multiple.\n *\n * Three inputs, in priority order:\n * 1. What the listing says. Explicit wording (\"included in asking price\", or its negation)\n * is a statement of fact from the seller and always wins.\n * 2. The arithmetic. When the stated real-estate value is at or above the asking price,\n * the building IS the ask — adding it again would quote an offer ceiling above the\n * price being asked, which is nonsense. This is the common case on listings that\n * publish a real-estate value and no inclusion wording at all.\n * 3. Otherwise include it, matching the default for every other asset: understating an\n * offer by silently dropping an asset is worse than an inclusion that can be toggled\n * off by hand.\n *\n * @param {{askingPrice?:number, realEstateValue?:number, statedInclusion?:boolean|null}} inputs\n * @returns {boolean}\n */\nexport function shouldIncludeRealEstate(inputs = {}) {\n if (typeof inputs.statedInclusion === \"boolean\") return inputs.statedInclusion;\n\n const askingPrice = toNumber(inputs.askingPrice);\n const realEstateValue = toNumber(inputs.realEstateValue);\n\n if (askingPrice !== null && askingPrice > 0 && realEstateValue !== null && realEstateValue >= askingPrice) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Down payment as the sum of per-asset collateral advances: 50% of the real\n * estate, 30% of FF&E, 20% of inventory, plus 20% of EBITDA once EBITDA reaches\n * $1M. Each leg is capped at its own rate, and a leg with no reported value\n * contributes nothing.\n *\n * The include flags gate the legs, because they gate the offer: an asset that is\n * not being bought cannot secure the down payment for the purchase. Without this\n * the two halves of the underwrite disagree — excluding the real estate would drop\n * it from the offer while still counting half its value as collateral, quoting a\n * down payment backed by a building that is not in the deal.\n *\n * The EBITDA leg reads EBITDA specifically, not the coalesced earnings figure —\n * an SDE-only listing does not qualify, because SDE includes owner compensation\n * and is not the same measure the threshold was set against. Its flag governs\n * collateral only; EBITDA is the base the offer multiple is taken against, so\n * adding it to the offer range as well would quote 4x while reporting 3x.\n *\n * @param {object} inputs\n * @param {number} [inputs.realEstateValue]\n * @param {number} [inputs.ffeValue]\n * @param {number} [inputs.inventoryValue]\n * @param {number} [inputs.ebitda]\n * @param {boolean} [inputs.includeRealEstate=true]\n * @param {boolean} [inputs.includeFfe=true]\n * @param {boolean} [inputs.includeInventory=true]\n * @param {boolean} [inputs.includeEbitda=true]\n * @returns {{downPayment:number, legs:{realEstate:number, ffe:number, inventory:number, ebitda:number}}}\n */\nexport function calculateBusinessDownPayment(inputs = {}) {\n const {\n EBITDA_ADVANCE_RATE,\n EBITDA_ADVANCE_THRESHOLD,\n FF_E_ADVANCE_RATE,\n INVENTORY_ADVANCE_RATE,\n REAL_ESTATE_ADVANCE_RATE,\n } = BUSINESS_UNDERWRITING_CONSTANTS;\n\n const {\n includeEbitda = true,\n includeFfe = true,\n includeInventory = true,\n includeRealEstate = true,\n } = inputs;\n\n const realEstateValue = includeRealEstate ? toNonNegative(inputs.realEstateValue) ?? 0 : 0;\n const ffeValue = includeFfe ? toNonNegative(inputs.ffeValue) ?? 0 : 0;\n const inventoryValue = includeInventory ? toNonNegative(inputs.inventoryValue) ?? 0 : 0;\n const ebitda = includeEbitda ? toNumber(inputs.ebitda) ?? 0 : 0;\n\n const legs = {\n ebitda: ebitda >= EBITDA_ADVANCE_THRESHOLD ? ebitda * EBITDA_ADVANCE_RATE : 0,\n ffe: ffeValue * FF_E_ADVANCE_RATE,\n inventory: inventoryValue * INVENTORY_ADVANCE_RATE,\n realEstate: realEstateValue * REAL_ESTATE_ADVANCE_RATE,\n };\n\n return {\n downPayment: legs.realEstate + legs.ffe + legs.inventory + legs.ebitda,\n legs,\n };\n}\n\n/**\n * The offer range: 2x to 3x earnings, plus the tangible assets that convey.\n * An asset the listing states is already covered by the asking price is excluded\n * by its include flag; the flags default to true, so an asset whose status could\n * not be determined is still offered on (dropping it silently would understate\n * the offer, which is the more damaging error).\n *\n * Returns nulls when no earnings figure exists — there is no honest multiple to\n * take, and quoting the assets alone would read as an offer.\n *\n * @param {object} inputs\n * @param {number} inputs.earnings - Earnings figure the multiple applies to\n * @param {number} [inputs.realEstateValue]\n * @param {number} [inputs.ffeValue]\n * @param {number} [inputs.inventoryValue]\n * @param {boolean} [inputs.includeRealEstate=true]\n * @param {boolean} [inputs.includeFfe=true]\n * @param {boolean} [inputs.includeInventory=true]\n * @returns {{assetsIncluded:number, offerHigh:number|null, offerLow:number|null}}\n */\nexport function calculateBusinessOffer(inputs = {}) {\n const { OFFER_MULTIPLE_HIGH, OFFER_MULTIPLE_LOW } = BUSINESS_UNDERWRITING_CONSTANTS;\n\n const {\n includeFfe = true,\n includeInventory = true,\n includeRealEstate = true,\n } = inputs;\n\n const assetsIncluded =\n (includeRealEstate ? toNonNegative(inputs.realEstateValue) ?? 0 : 0) +\n (includeFfe ? toNonNegative(inputs.ffeValue) ?? 0 : 0) +\n (includeInventory ? toNonNegative(inputs.inventoryValue) ?? 0 : 0);\n\n const earnings = toNumber(inputs.earnings);\n if (earnings === null) return { assetsIncluded, offerHigh: null, offerLow: null };\n\n return {\n assetsIncluded,\n offerHigh: earnings * OFFER_MULTIPLE_HIGH + assetsIncluded,\n offerLow: earnings * OFFER_MULTIPLE_LOW + assetsIncluded,\n };\n}\n\n/**\n * The seller-carry terms for the price actually being offered.\n *\n * The offer range is a range; the LOI needs one number. priceOffered is that\n * choice, and the seller carries whatever the collateral down payment does not\n * cover. From there the arithmetic is the property engine's, so both sides quote\n * a balloon identically — a business is worked like a rental, only the earnings\n * measure differs.\n *\n * Returns nulls when no price has been offered yet. A zeroed payment schedule\n * would read as real terms on an LOI, which is the more damaging error.\n *\n * @param {object} inputs\n * @param {number} inputs.priceOffered - The figure being offered\n * @param {number} inputs.downPayment - Collateral-derived down payment\n * @param {number} [inputs.interestRate=0] - Annual seller-finance rate, as a decimal\n * @param {number} [inputs.amortizationYears=30]\n * @param {number} [inputs.balloonYears=7]\n * @param {string} [inputs.interestPaymentMode=\"standard\"] - standard | simple_payout | compound_payout\n * @returns {{sellerFinanced:number|null, sfPayment:number|null, balloonBalance:number|null, performancePayout:number, totalPayments:number|null}}\n */\nexport function calculateBusinessSellerFinance(inputs = {}) {\n const {\n amortizationYears = FINANCIAL_CONSTANTS.SELLER_FI_AMORTIZATION,\n balloonYears = FINANCIAL_CONSTANTS.DEFAULT_BALLOON_PERIOD_YEARS,\n interestPaymentMode = \"standard\",\n interestRate = FINANCIAL_CONSTANTS.SELLER_FI_INTEREST_RATE,\n } = inputs;\n\n const priceOffered = toNumber(inputs.priceOffered);\n if (priceOffered === null) {\n return {\n balloonBalance: null,\n performancePayout: 0,\n sellerFinanced: null,\n sfPayment: null,\n totalPayments: null,\n };\n }\n\n const downPayment = toNonNegative(inputs.downPayment) ?? 0;\n // Never negative: collateral outrunning the offer is reported as a shortfall by\n // the caller, not folded back in here as a carry the seller owes the buyer.\n const sellerFinanced = Math.max(0, priceOffered - downPayment);\n\n let performancePayout = 0;\n let sfPayment;\n\n if (interestPaymentMode === \"standard\") {\n sfPayment = calculatePMT(sellerFinanced, interestRate, amortizationYears);\n } else {\n // Payout modes defer all interest to the balloon, so the running payment is\n // principal-only and the balloon carries the accrued interest instead.\n sfPayment = calculatePMT(sellerFinanced, 0, amortizationYears);\n if (interestPaymentMode === \"simple_payout\") {\n performancePayout = sellerFinanced * interestRate * balloonYears;\n } else if (interestPaymentMode === \"compound_payout\") {\n performancePayout = sellerFinanced * (Math.pow(1 + interestRate, balloonYears) - 1);\n }\n }\n\n // Mirrors the property engine: amortized down in standard mode, full principal\n // in the payout modes, with the deferred interest added on top.\n const baseBalloonBalance = interestPaymentMode === \"standard\"\n ? calculateBalloonBalance(sellerFinanced, interestRate, amortizationYears, balloonYears)\n : sellerFinanced;\n\n return {\n balloonBalance: baseBalloonBalance + performancePayout,\n performancePayout,\n sellerFinanced,\n sfPayment,\n totalPayments: sfPayment * 12 * balloonYears,\n };\n}\n\n/**\n * Full underwrite for one listing: resolve earnings, size the down payment from\n * collateral, and bound the offer. The seller carries the balance of the ceiling\n * offer as preferred equity.\n *\n * collateralShortfall is set when the collateral-driven down payment exceeds the\n * offer itself — a real signal that the asset value has outrun what the earnings\n * multiple justifies. It is surfaced, never clamped away.\n *\n * @param {object} listing - Scraped/stored figures (snake_case or camelCase)\n * @returns {object} earnings, earningsSource, downPayment, legs, offerLow, offerHigh, sellerCarry, collateralShortfall\n */\nexport function underwriteBusinessListing(listing = {}) {\n const realEstateValue = listing.realEstateValue ?? listing.real_estate_value;\n const ffeValue = listing.ffeValue ?? listing.ff_e_value;\n const inventoryValue = listing.inventoryValue ?? listing.inventory_value;\n const ebitda = listing.ebitda;\n\n const { earnings, source } = resolveBusinessEarnings({\n cashFlow: listing.cashFlow ?? listing.cash_flow,\n ebitda,\n sde: listing.sde,\n });\n\n // One set of flags drives both the collateral stack and the offer range, so an\n // asset excluded from the deal leaves both at once.\n const includeEbitda = listing.includeEbitda ?? listing.include_ebitda ?? true;\n const includeFfe = listing.includeFfe ?? listing.include_ff_e ?? true;\n const includeInventory = listing.includeInventory ?? listing.include_inventory ?? true;\n const includeRealEstate = listing.includeRealEstate ?? listing.include_real_estate ?? true;\n\n const { downPayment, legs } = calculateBusinessDownPayment({\n ebitda,\n ffeValue,\n includeEbitda,\n includeFfe,\n includeInventory,\n includeRealEstate,\n inventoryValue,\n realEstateValue,\n });\n\n const { assetsIncluded, offerHigh, offerLow } = calculateBusinessOffer({\n earnings,\n ffeValue,\n includeFfe,\n includeInventory,\n includeRealEstate,\n inventoryValue,\n realEstateValue,\n });\n\n // Terms for the price actually offered. Kept separate from the range figures\n // above so there is no mistaking the deal being made for the ceiling that\n // bounds it — sellerCarry is the carry at offerHigh, terms.sellerFinanced the\n // carry at the price on the LOI.\n const terms = calculateBusinessSellerFinance({\n amortizationYears: listing.sellerAmortization ?? listing.seller_amortization ?? undefined,\n balloonYears: listing.balloonLength ?? listing.balloon_length ?? undefined,\n downPayment,\n interestPaymentMode: listing.interestPaymentMode ?? listing.interest_payment_mode ?? \"standard\",\n interestRate: listing.sellerFiRate ?? listing.seller_fi_rate ?? undefined,\n priceOffered: listing.priceOffered ?? listing.price_offered,\n });\n\n return {\n assetsIncluded,\n collateralShortfall: offerHigh !== null && downPayment > offerHigh,\n downPayment,\n earnings,\n earningsSource: listing.earningsSource ?? listing.earnings_source ?? source,\n legs,\n offerHigh,\n offerLow,\n priceOffered: toNumber(listing.priceOffered ?? listing.price_offered),\n sellerCarry: offerHigh === null ? null : offerHigh - downPayment,\n terms,\n };\n}\n"],"names":["BUSINESS_UNDERWRITING_CONSTANTS","EBITDA_ADVANCE_RATE","EBITDA_ADVANCE_THRESHOLD","FF_E_ADVANCE_RATE","INVENTORY_ADVANCE_RATE","OFFER_MULTIPLE_HIGH","OFFER_MULTIPLE_LOW","REAL_ESTATE_ADVANCE_RATE","toNumber","value","numeric","parseFloat","String","replace","Number","isFinite","toNonNegative","resolveBusinessEarnings","listing","ebitda","earnings","source","sde","cashFlow","cash_flow","shouldIncludeRealEstate","inputs","statedInclusion","askingPrice","realEstateValue","calculateBusinessDownPayment","includeEbitda","includeFfe","includeInventory","includeRealEstate","ffeValue","inventoryValue","legs","ffe","inventory","realEstate","downPayment","calculateBusinessOffer","assetsIncluded","offerHigh","offerLow","calculateBusinessSellerFinance","amortizationYears","FINANCIAL_CONSTANTS","SELLER_FI_AMORTIZATION","balloonYears","DEFAULT_BALLOON_PERIOD_YEARS","interestPaymentMode","interestRate","SELLER_FI_INTEREST_RATE","priceOffered","balloonBalance","performancePayout","sellerFinanced","sfPayment","totalPayments","Math","max","calculatePMT","pow","calculateBalloonBalance","underwriteBusinessListing","real_estate_value","ff_e_value","inventory_value","include_ebitda","include_ff_e","include_inventory","include_real_estate","terms","sellerAmortization","seller_amortization","undefined","balloonLength","balloon_length","interest_payment_mode","sellerFiRate","seller_fi_rate","price_offered","collateralShortfall","earningsSource","earnings_source","sellerCarry"],"mappings":"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"}
1
+ {"version":3,"file":"business-underwriting.js","sources":["../../src/financial/business-underwriting.js"],"sourcesContent":["// src/financial/business-underwriting.js\n//\n// Underwriting for businesses backed by real estate (the BizBuySell pipeline).\n// The real-estate model prices a property off NOI and a cap rate; this one\n// prices a business off an earnings multiple and adds the tangible assets that\n// convey with it. Shared by the BizBuySell extension panel and the dashboard so\n// both quote the same numbers from the same inputs.\n\nimport { FINANCIAL_CONSTANTS } from \"../config/financial.js\";\nimport { calculateBalloonBalance, calculatePMT } from \"./calculations.js\";\n\n/**\n * Collateral advance rates for the down payment, and the earnings multiples\n * bounding the offer. The EBITDA leg only contributes at or above its threshold\n * — smaller earnings are not treated as collateral.\n */\nexport const BUSINESS_UNDERWRITING_CONSTANTS = {\n EBITDA_ADVANCE_RATE: 0.20,\n EBITDA_ADVANCE_THRESHOLD: 1000000,\n FF_E_ADVANCE_RATE: 0.30,\n INVENTORY_ADVANCE_RATE: 0.20,\n OFFER_MULTIPLE_HIGH: 3,\n OFFER_MULTIPLE_LOW: 2,\n REAL_ESTATE_ADVANCE_RATE: 0.50,\n};\n\n// Coerce a scraped/stored figure to a finite number, else null. Absent data must\n// stay absent — a missing earnings figure means \"no offer\", never \"an offer of 0\".\nfunction toNumber(value) {\n if (value === null || value === undefined || value === \"\") return null;\n const numeric = typeof value === \"number\" ? value : parseFloat(String(value).replace(/[$,\\s]/g, \"\"));\n return Number.isFinite(numeric) ? numeric : null;\n}\n\nfunction toNonNegative(value) {\n const numeric = toNumber(value);\n return numeric === null || numeric < 0 ? null : numeric;\n}\n\n/**\n * Pick the earnings figure that drives the offer multiple, preferring EBITDA and\n * falling back to SDE then cash flow — most BizBuySell listings publish only\n * \"Cash Flow (SDE)\". The source is returned alongside so callers can label which\n * figure the multiple was applied to instead of implying it was EBITDA.\n * @param {{ebitda?:number, sde?:number, cashFlow?:number}} listing\n * @returns {{earnings:number|null, source:string|null}}\n */\nexport function resolveBusinessEarnings(listing = {}) {\n const ebitda = toNumber(listing.ebitda);\n if (ebitda !== null) return { earnings: ebitda, source: \"ebitda\" };\n\n const sde = toNumber(listing.sde);\n if (sde !== null) return { earnings: sde, source: \"sde\" };\n\n const cashFlow = toNumber(listing.cashFlow ?? listing.cash_flow);\n if (cashFlow !== null) return { earnings: cashFlow, source: \"cash_flow\" };\n\n return { earnings: null, source: null };\n}\n\n/**\n * Whether the real estate should be ADDED to the offer on top of the earnings multiple.\n *\n * Three inputs, in priority order:\n * 1. What the listing says. Explicit wording (\"included in asking price\", or its negation)\n * is a statement of fact from the seller and always wins.\n * 2. The arithmetic. When the stated real-estate value is at or above the asking price,\n * the building IS the ask — adding it again would quote an offer ceiling above the\n * price being asked, which is nonsense. This is the common case on listings that\n * publish a real-estate value and no inclusion wording at all.\n * 3. Otherwise include it, matching the default for every other asset: understating an\n * offer by silently dropping an asset is worse than an inclusion that can be toggled\n * off by hand.\n *\n * @param {{askingPrice?:number, realEstateValue?:number, statedInclusion?:boolean|null}} inputs\n * @returns {boolean}\n */\nexport function shouldIncludeRealEstate(inputs = {}) {\n if (typeof inputs.statedInclusion === \"boolean\") return inputs.statedInclusion;\n\n const askingPrice = toNumber(inputs.askingPrice);\n const realEstateValue = toNumber(inputs.realEstateValue);\n\n if (askingPrice !== null && askingPrice > 0 && realEstateValue !== null && realEstateValue >= askingPrice) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Down payment as the sum of per-asset collateral advances: 50% of the real\n * estate, 30% of FF&E, 20% of inventory, plus 20% of EBITDA once EBITDA reaches\n * $1M. Each leg is capped at its own rate, and a leg with no reported value\n * contributes nothing.\n *\n * Deliberately NOT gated by the include_* flags. Those flags answer a different\n * question — whether an asset is ADDED to the offer on top of the earnings\n * multiple — and an asset excluded there is still being acquired. The common case\n * is a building whose value equals the asking price: it is not added to the offer\n * (that would quote a ceiling above the ask) but it is still bought, and it still\n * secures half its value. Gating this on those flags zeroes the collateral on\n * exactly the listings where the real estate matters most.\n *\n * The EBITDA leg reads EBITDA specifically, not the coalesced earnings figure —\n * an SDE-only listing does not qualify, because SDE includes owner compensation\n * and is not the same measure the threshold was set against. collateralizeEbitda\n * is a collateral-only switch, named apart from the include_* flags because it\n * answers that different question: EBITDA is the base the offer multiple is taken\n * against, so it never joins the offer range at all.\n *\n * @param {object} inputs\n * @param {number} [inputs.realEstateValue]\n * @param {number} [inputs.ffeValue]\n * @param {number} [inputs.inventoryValue]\n * @param {number} [inputs.ebitda]\n * @param {boolean} [inputs.collateralizeEbitda=true]\n * @returns {{downPayment:number, legs:{realEstate:number, ffe:number, inventory:number, ebitda:number}}}\n */\nexport function calculateBusinessDownPayment(inputs = {}) {\n const {\n EBITDA_ADVANCE_RATE,\n EBITDA_ADVANCE_THRESHOLD,\n FF_E_ADVANCE_RATE,\n INVENTORY_ADVANCE_RATE,\n REAL_ESTATE_ADVANCE_RATE,\n } = BUSINESS_UNDERWRITING_CONSTANTS;\n\n const { collateralizeEbitda = true } = inputs;\n\n const realEstateValue = toNonNegative(inputs.realEstateValue) ?? 0;\n const ffeValue = toNonNegative(inputs.ffeValue) ?? 0;\n const inventoryValue = toNonNegative(inputs.inventoryValue) ?? 0;\n const ebitda = collateralizeEbitda ? toNumber(inputs.ebitda) ?? 0 : 0;\n\n const legs = {\n ebitda: ebitda >= EBITDA_ADVANCE_THRESHOLD ? ebitda * EBITDA_ADVANCE_RATE : 0,\n ffe: ffeValue * FF_E_ADVANCE_RATE,\n inventory: inventoryValue * INVENTORY_ADVANCE_RATE,\n realEstate: realEstateValue * REAL_ESTATE_ADVANCE_RATE,\n };\n\n return {\n downPayment: legs.realEstate + legs.ffe + legs.inventory + legs.ebitda,\n legs,\n };\n}\n\n/**\n * The offer range: 2x to 3x earnings, plus the tangible assets that convey.\n * An asset the listing states is already covered by the asking price is excluded\n * by its include flag; the flags default to true, so an asset whose status could\n * not be determined is still offered on (dropping it silently would understate\n * the offer, which is the more damaging error).\n *\n * Returns nulls when no earnings figure exists — there is no honest multiple to\n * take, and quoting the assets alone would read as an offer.\n *\n * @param {object} inputs\n * @param {number} inputs.earnings - Earnings figure the multiple applies to\n * @param {number} [inputs.realEstateValue]\n * @param {number} [inputs.ffeValue]\n * @param {number} [inputs.inventoryValue]\n * @param {boolean} [inputs.includeRealEstate=true]\n * @param {boolean} [inputs.includeFfe=true]\n * @param {boolean} [inputs.includeInventory=true]\n * @returns {{assetsIncluded:number, offerHigh:number|null, offerLow:number|null}}\n */\nexport function calculateBusinessOffer(inputs = {}) {\n const { OFFER_MULTIPLE_HIGH, OFFER_MULTIPLE_LOW } = BUSINESS_UNDERWRITING_CONSTANTS;\n\n const {\n includeFfe = true,\n includeInventory = true,\n includeRealEstate = true,\n } = inputs;\n\n const assetsIncluded =\n (includeRealEstate ? toNonNegative(inputs.realEstateValue) ?? 0 : 0) +\n (includeFfe ? toNonNegative(inputs.ffeValue) ?? 0 : 0) +\n (includeInventory ? toNonNegative(inputs.inventoryValue) ?? 0 : 0);\n\n const earnings = toNumber(inputs.earnings);\n if (earnings === null) return { assetsIncluded, offerHigh: null, offerLow: null };\n\n return {\n assetsIncluded,\n offerHigh: earnings * OFFER_MULTIPLE_HIGH + assetsIncluded,\n offerLow: earnings * OFFER_MULTIPLE_LOW + assetsIncluded,\n };\n}\n\n/**\n * The seller-carry terms for the price actually being offered.\n *\n * The offer range is a range; the LOI needs one number. priceOffered is that\n * choice, and the seller carries whatever the collateral down payment does not\n * cover. From there the arithmetic is the property engine's, so both sides quote\n * a balloon identically — a business is worked like a rental, only the earnings\n * measure differs.\n *\n * Returns nulls when no price has been offered yet. A zeroed payment schedule\n * would read as real terms on an LOI, which is the more damaging error.\n *\n * @param {object} inputs\n * @param {number} inputs.priceOffered - The figure being offered\n * @param {number} inputs.downPayment - Collateral-derived down payment\n * @param {number} [inputs.interestRate=0] - Annual seller-finance rate, as a decimal\n * @param {number} [inputs.amortizationYears=30]\n * @param {number} [inputs.balloonYears=7]\n * @param {string} [inputs.interestPaymentMode=\"standard\"] - standard | simple_payout | compound_payout\n * @returns {{sellerFinanced:number|null, sfPayment:number|null, balloonBalance:number|null, performancePayout:number, totalPayments:number|null}}\n */\nexport function calculateBusinessSellerFinance(inputs = {}) {\n const {\n amortizationYears = FINANCIAL_CONSTANTS.SELLER_FI_AMORTIZATION,\n balloonYears = FINANCIAL_CONSTANTS.DEFAULT_BALLOON_PERIOD_YEARS,\n interestPaymentMode = \"standard\",\n interestRate = FINANCIAL_CONSTANTS.SELLER_FI_INTEREST_RATE,\n } = inputs;\n\n const priceOffered = toNumber(inputs.priceOffered);\n if (priceOffered === null) {\n return {\n balloonBalance: null,\n performancePayout: 0,\n sellerFinanced: null,\n sfPayment: null,\n totalPayments: null,\n };\n }\n\n const downPayment = toNonNegative(inputs.downPayment) ?? 0;\n // Never negative: collateral outrunning the offer is reported as a shortfall by\n // the caller, not folded back in here as a carry the seller owes the buyer.\n const sellerFinanced = Math.max(0, priceOffered - downPayment);\n\n let performancePayout = 0;\n let sfPayment;\n\n if (interestPaymentMode === \"standard\") {\n sfPayment = calculatePMT(sellerFinanced, interestRate, amortizationYears);\n } else {\n // Payout modes defer all interest to the balloon, so the running payment is\n // principal-only and the balloon carries the accrued interest instead.\n sfPayment = calculatePMT(sellerFinanced, 0, amortizationYears);\n if (interestPaymentMode === \"simple_payout\") {\n performancePayout = sellerFinanced * interestRate * balloonYears;\n } else if (interestPaymentMode === \"compound_payout\") {\n performancePayout = sellerFinanced * (Math.pow(1 + interestRate, balloonYears) - 1);\n }\n }\n\n // Mirrors the property engine: amortized down in standard mode, full principal\n // in the payout modes, with the deferred interest added on top.\n const baseBalloonBalance = interestPaymentMode === \"standard\"\n ? calculateBalloonBalance(sellerFinanced, interestRate, amortizationYears, balloonYears)\n : sellerFinanced;\n\n return {\n balloonBalance: baseBalloonBalance + performancePayout,\n performancePayout,\n sellerFinanced,\n sfPayment,\n totalPayments: sfPayment * 12 * balloonYears,\n };\n}\n\n/**\n * Full underwrite for one listing: resolve earnings, size the down payment from\n * collateral, and bound the offer. The seller carries the balance of the ceiling\n * offer as preferred equity.\n *\n * collateralShortfall is set when the collateral-driven down payment exceeds the\n * offer itself — a real signal that the asset value has outrun what the earnings\n * multiple justifies. It is surfaced, never clamped away.\n *\n * @param {object} listing - Scraped/stored figures (snake_case or camelCase)\n * @returns {object} earnings, earningsSource, downPayment, legs, offerLow, offerHigh, sellerCarry, collateralShortfall\n */\nexport function underwriteBusinessListing(listing = {}) {\n const realEstateValue = listing.realEstateValue ?? listing.real_estate_value;\n const ffeValue = listing.ffeValue ?? listing.ff_e_value;\n const inventoryValue = listing.inventoryValue ?? listing.inventory_value;\n const ebitda = listing.ebitda;\n\n const { earnings, source } = resolveBusinessEarnings({\n cashFlow: listing.cashFlow ?? listing.cash_flow,\n ebitda,\n sde: listing.sde,\n });\n\n // The collateral stack covers everything being acquired, so it takes no include_*\n // flag — those govern only what is ADDED to the offer range. Its one switch is\n // collateralizeEbitda, which has no offer-range counterpart.\n const { downPayment, legs } = calculateBusinessDownPayment({\n collateralizeEbitda: listing.collateralizeEbitda ?? listing.collateralize_ebitda ?? true,\n ebitda,\n ffeValue,\n inventoryValue,\n realEstateValue,\n });\n\n const { assetsIncluded, offerHigh, offerLow } = calculateBusinessOffer({\n earnings,\n ffeValue,\n includeFfe: listing.includeFfe ?? listing.include_ff_e ?? true,\n includeInventory: listing.includeInventory ?? listing.include_inventory ?? true,\n includeRealEstate: listing.includeRealEstate ?? listing.include_real_estate ?? true,\n inventoryValue,\n realEstateValue,\n });\n\n // Terms for the price actually offered. Kept separate from the range figures\n // above so there is no mistaking the deal being made for the ceiling that\n // bounds it — sellerCarry is the carry at offerHigh, terms.sellerFinanced the\n // carry at the price on the LOI.\n const terms = calculateBusinessSellerFinance({\n amortizationYears: listing.sellerAmortization ?? listing.seller_amortization ?? undefined,\n balloonYears: listing.balloonLength ?? listing.balloon_length ?? undefined,\n downPayment,\n interestPaymentMode: listing.interestPaymentMode ?? listing.interest_payment_mode ?? \"standard\",\n interestRate: listing.sellerFiRate ?? listing.seller_fi_rate ?? undefined,\n priceOffered: listing.priceOffered ?? listing.price_offered,\n });\n\n return {\n assetsIncluded,\n collateralShortfall: offerHigh !== null && downPayment > offerHigh,\n downPayment,\n earnings,\n earningsSource: listing.earningsSource ?? listing.earnings_source ?? source,\n legs,\n offerHigh,\n offerLow,\n priceOffered: toNumber(listing.priceOffered ?? listing.price_offered),\n sellerCarry: offerHigh === null ? null : offerHigh - downPayment,\n terms,\n };\n}\n"],"names":["BUSINESS_UNDERWRITING_CONSTANTS","EBITDA_ADVANCE_RATE","EBITDA_ADVANCE_THRESHOLD","FF_E_ADVANCE_RATE","INVENTORY_ADVANCE_RATE","OFFER_MULTIPLE_HIGH","OFFER_MULTIPLE_LOW","REAL_ESTATE_ADVANCE_RATE","toNumber","value","numeric","parseFloat","String","replace","Number","isFinite","toNonNegative","resolveBusinessEarnings","listing","ebitda","earnings","source","sde","cashFlow","cash_flow","shouldIncludeRealEstate","inputs","statedInclusion","askingPrice","realEstateValue","calculateBusinessDownPayment","collateralizeEbitda","ffeValue","inventoryValue","legs","ffe","inventory","realEstate","downPayment","calculateBusinessOffer","includeFfe","includeInventory","includeRealEstate","assetsIncluded","offerHigh","offerLow","calculateBusinessSellerFinance","amortizationYears","FINANCIAL_CONSTANTS","SELLER_FI_AMORTIZATION","balloonYears","DEFAULT_BALLOON_PERIOD_YEARS","interestPaymentMode","interestRate","SELLER_FI_INTEREST_RATE","priceOffered","balloonBalance","performancePayout","sellerFinanced","sfPayment","totalPayments","Math","max","calculatePMT","pow","calculateBalloonBalance","underwriteBusinessListing","real_estate_value","ff_e_value","inventory_value","collateralize_ebitda","include_ff_e","include_inventory","include_real_estate","terms","sellerAmortization","seller_amortization","undefined","balloonLength","balloon_length","interest_payment_mode","sellerFiRate","seller_fi_rate","price_offered","collateralShortfall","earningsSource","earnings_source","sellerCarry"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archerjessop/utilities",
3
- "version": "7.32.0",
3
+ "version": "7.32.1",
4
4
  "description": "Shared utilities for ArcherJessop property analysis tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",