mortctl 0.1.0
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.
- package/README.md +234 -0
- package/dist/commands/eligible.d.ts +6 -0
- package/dist/commands/eligible.d.ts.map +1 -0
- package/dist/commands/eligible.js +115 -0
- package/dist/commands/eligible.js.map +1 -0
- package/dist/commands/limits.d.ts +6 -0
- package/dist/commands/limits.d.ts.map +1 -0
- package/dist/commands/limits.js +89 -0
- package/dist/commands/limits.js.map +1 -0
- package/dist/commands/ltv.d.ts +6 -0
- package/dist/commands/ltv.d.ts.map +1 -0
- package/dist/commands/ltv.js +96 -0
- package/dist/commands/ltv.js.map +1 -0
- package/dist/commands/payment.d.ts +6 -0
- package/dist/commands/payment.d.ts.map +1 -0
- package/dist/commands/payment.js +98 -0
- package/dist/commands/payment.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/calculations.d.ts +52 -0
- package/dist/lib/calculations.d.ts.map +1 -0
- package/dist/lib/calculations.js +270 -0
- package/dist/lib/calculations.js.map +1 -0
- package/dist/lib/eligibility.d.ts +33 -0
- package/dist/lib/eligibility.d.ts.map +1 -0
- package/dist/lib/eligibility.js +296 -0
- package/dist/lib/eligibility.js.map +1 -0
- package/dist/types.d.ts +179 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/jest.config.js +8 -0
- package/package.json +46 -0
- package/src/commands/eligible.ts +121 -0
- package/src/commands/limits.ts +91 -0
- package/src/commands/ltv.ts +97 -0
- package/src/commands/payment.ts +100 -0
- package/src/index.ts +32 -0
- package/src/lib/calculations.ts +314 -0
- package/src/lib/eligibility.ts +343 -0
- package/src/types.ts +216 -0
- package/tests/calculations.test.ts +154 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core mortgage calculations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
LtvResult,
|
|
7
|
+
PmiInputs,
|
|
8
|
+
PmiResult,
|
|
9
|
+
LoanScenario,
|
|
10
|
+
PaymentBreakdown,
|
|
11
|
+
LoanLimits,
|
|
12
|
+
} from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 2026 Conforming Loan Limits (estimated)
|
|
16
|
+
*/
|
|
17
|
+
export const LOAN_LIMITS_2026: LoanLimits = {
|
|
18
|
+
baseline: 766550, // Standard conforming limit
|
|
19
|
+
highCost: 1149825, // High-cost area ceiling (150% of baseline)
|
|
20
|
+
fhaFloor: 498257, // FHA floor
|
|
21
|
+
fhaCeiling: 1149825, // FHA ceiling (same as high-cost)
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* High-cost counties (sample - would be comprehensive in production)
|
|
26
|
+
*/
|
|
27
|
+
export const HIGH_COST_COUNTIES: Record<string, number> = {
|
|
28
|
+
'Los Angeles, CA': 1149825,
|
|
29
|
+
'San Francisco, CA': 1149825,
|
|
30
|
+
'New York, NY': 1149825,
|
|
31
|
+
'Kings, NY': 1149825,
|
|
32
|
+
'Queens, NY': 1149825,
|
|
33
|
+
'Bronx, NY': 1149825,
|
|
34
|
+
'San Diego, CA': 1006250,
|
|
35
|
+
'Orange, CA': 1149825,
|
|
36
|
+
'Santa Clara, CA': 1149825,
|
|
37
|
+
'Alameda, CA': 1149825,
|
|
38
|
+
'Seattle, WA': 977500,
|
|
39
|
+
'King, WA': 977500,
|
|
40
|
+
'Honolulu, HI': 1149825,
|
|
41
|
+
'Washington, DC': 1149825,
|
|
42
|
+
'Montgomery, MD': 1149825,
|
|
43
|
+
'Fairfax, VA': 1149825,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get conforming loan limit for a county
|
|
48
|
+
*/
|
|
49
|
+
export function getConformingLimit(county?: string, units: number = 1): number {
|
|
50
|
+
let baseLimit = LOAN_LIMITS_2026.baseline;
|
|
51
|
+
|
|
52
|
+
if (county && HIGH_COST_COUNTIES[county]) {
|
|
53
|
+
baseLimit = HIGH_COST_COUNTIES[county];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Multi-unit adjustments
|
|
57
|
+
const unitMultipliers: Record<number, number> = {
|
|
58
|
+
1: 1,
|
|
59
|
+
2: 1.28,
|
|
60
|
+
3: 1.55,
|
|
61
|
+
4: 1.92,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return Math.round(baseLimit * (unitMultipliers[units] || 1));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Calculate monthly P&I payment
|
|
69
|
+
*/
|
|
70
|
+
export function calculateMonthlyPayment(
|
|
71
|
+
principal: number,
|
|
72
|
+
annualRate: number,
|
|
73
|
+
termYears: number
|
|
74
|
+
): number {
|
|
75
|
+
const monthlyRate = annualRate / 100 / 12;
|
|
76
|
+
const numPayments = termYears * 12;
|
|
77
|
+
|
|
78
|
+
if (monthlyRate === 0) {
|
|
79
|
+
return principal / numPayments;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const payment = principal *
|
|
83
|
+
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
|
|
84
|
+
(Math.pow(1 + monthlyRate, numPayments) - 1);
|
|
85
|
+
|
|
86
|
+
return Math.round(payment * 100) / 100;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Calculate LTV/CLTV/HCLTV
|
|
91
|
+
*/
|
|
92
|
+
export function calculateLtv(
|
|
93
|
+
propertyValue: number,
|
|
94
|
+
loanAmount: number,
|
|
95
|
+
secondLien: number = 0,
|
|
96
|
+
heloc: number = 0
|
|
97
|
+
): LtvResult {
|
|
98
|
+
const ltv = (loanAmount / propertyValue) * 100;
|
|
99
|
+
const cltv = ((loanAmount + secondLien) / propertyValue) * 100;
|
|
100
|
+
const hcltv = ((loanAmount + secondLien + heloc) / propertyValue) * 100;
|
|
101
|
+
|
|
102
|
+
const pmiRequired = ltv > 80;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ltv: Math.round(ltv * 100) / 100,
|
|
106
|
+
cltv: Math.round(cltv * 100) / 100,
|
|
107
|
+
hcltv: Math.round(hcltv * 100) / 100,
|
|
108
|
+
pmiRequired,
|
|
109
|
+
pmiRemovalLtv: pmiRequired ? 78 : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* PMI rate lookup table (approximate)
|
|
115
|
+
* Format: [LTV range][Credit score range] = annual rate %
|
|
116
|
+
*/
|
|
117
|
+
const PMI_RATES: Record<string, Record<string, number>> = {
|
|
118
|
+
'95-97': { '760+': 0.55, '740-759': 0.65, '720-739': 0.78, '700-719': 0.95, '680-699': 1.15, '<680': 1.40 },
|
|
119
|
+
'90-95': { '760+': 0.38, '740-759': 0.45, '720-739': 0.55, '700-719': 0.70, '680-699': 0.90, '<680': 1.15 },
|
|
120
|
+
'85-90': { '760+': 0.25, '740-759': 0.30, '720-739': 0.38, '700-719': 0.50, '680-699': 0.65, '<680': 0.85 },
|
|
121
|
+
'80-85': { '760+': 0.15, '740-759': 0.18, '720-739': 0.23, '700-719': 0.30, '680-699': 0.40, '<680': 0.55 },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get credit score bracket for PMI lookup
|
|
126
|
+
*/
|
|
127
|
+
function getCreditBracket(score: number): string {
|
|
128
|
+
if (score >= 760) return '760+';
|
|
129
|
+
if (score >= 740) return '740-759';
|
|
130
|
+
if (score >= 720) return '720-739';
|
|
131
|
+
if (score >= 700) return '700-719';
|
|
132
|
+
if (score >= 680) return '680-699';
|
|
133
|
+
return '<680';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get LTV bracket for PMI lookup
|
|
138
|
+
*/
|
|
139
|
+
function getLtvBracket(ltv: number): string {
|
|
140
|
+
if (ltv > 95) return '95-97';
|
|
141
|
+
if (ltv > 90) return '90-95';
|
|
142
|
+
if (ltv > 85) return '85-90';
|
|
143
|
+
if (ltv > 80) return '80-85';
|
|
144
|
+
return '80-85'; // Below 80 - no PMI normally
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Calculate PMI
|
|
149
|
+
*/
|
|
150
|
+
export function calculatePmi(inputs: PmiInputs): PmiResult {
|
|
151
|
+
const { ltv, creditScore, loanAmount, termYears = 30 } = inputs;
|
|
152
|
+
|
|
153
|
+
// No PMI below 80% LTV
|
|
154
|
+
if (ltv <= 80) {
|
|
155
|
+
return {
|
|
156
|
+
required: false,
|
|
157
|
+
monthlyAmount: 0,
|
|
158
|
+
annualAmount: 0,
|
|
159
|
+
ratePercent: 0,
|
|
160
|
+
cancellationLtv: 80,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ltvBracket = getLtvBracket(ltv);
|
|
165
|
+
const creditBracket = getCreditBracket(creditScore);
|
|
166
|
+
|
|
167
|
+
const ratePercent = PMI_RATES[ltvBracket]?.[creditBracket] || 0.85;
|
|
168
|
+
const annualAmount = (loanAmount * ratePercent) / 100;
|
|
169
|
+
const monthlyAmount = annualAmount / 12;
|
|
170
|
+
|
|
171
|
+
// Estimate months to reach 78% LTV (automatic cancellation)
|
|
172
|
+
// Simplified - assumes 30-year at average rate
|
|
173
|
+
const avgRate = 6.5; // Approximate
|
|
174
|
+
const monthlyPayment = calculateMonthlyPayment(loanAmount, avgRate, termYears);
|
|
175
|
+
|
|
176
|
+
// Very rough estimate of months to 78% LTV
|
|
177
|
+
const targetLoanAmount = loanAmount * (78 / ltv);
|
|
178
|
+
const reductionNeeded = loanAmount - targetLoanAmount;
|
|
179
|
+
|
|
180
|
+
// Approximate based on early amortization (mostly interest)
|
|
181
|
+
const avgMonthlyPrincipal = monthlyPayment * 0.25; // Rough estimate
|
|
182
|
+
const monthsToCancel = Math.ceil(reductionNeeded / avgMonthlyPrincipal);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
required: true,
|
|
186
|
+
monthlyAmount: Math.round(monthlyAmount * 100) / 100,
|
|
187
|
+
annualAmount: Math.round(annualAmount * 100) / 100,
|
|
188
|
+
ratePercent,
|
|
189
|
+
cancellationLtv: 78,
|
|
190
|
+
monthsToCancel,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Calculate full payment breakdown
|
|
196
|
+
*/
|
|
197
|
+
export function calculatePaymentBreakdown(scenario: LoanScenario): PaymentBreakdown {
|
|
198
|
+
const principalAndInterest = calculateMonthlyPayment(
|
|
199
|
+
scenario.loanAmount,
|
|
200
|
+
scenario.interestRate,
|
|
201
|
+
scenario.termYears
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const propertyTaxes = (scenario.propertyTaxes || scenario.propertyValue * 0.0125) / 12;
|
|
205
|
+
const homeownersInsurance = (scenario.homeownersInsurance || scenario.propertyValue * 0.0035) / 12;
|
|
206
|
+
const hoaDues = scenario.hoaDues || 0;
|
|
207
|
+
|
|
208
|
+
// Calculate PMI if needed
|
|
209
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
210
|
+
const pmiResult = calculatePmi({
|
|
211
|
+
ltv,
|
|
212
|
+
creditScore: scenario.creditScore,
|
|
213
|
+
loanAmount: scenario.loanAmount,
|
|
214
|
+
termYears: scenario.termYears,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const totalPayment = principalAndInterest + propertyTaxes + homeownersInsurance +
|
|
218
|
+
pmiResult.monthlyAmount + hoaDues;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
principalAndInterest: Math.round(principalAndInterest * 100) / 100,
|
|
222
|
+
propertyTaxes: Math.round(propertyTaxes * 100) / 100,
|
|
223
|
+
homeownersInsurance: Math.round(homeownersInsurance * 100) / 100,
|
|
224
|
+
mortgageInsurance: pmiResult.monthlyAmount,
|
|
225
|
+
hoaDues,
|
|
226
|
+
totalPayment: Math.round(totalPayment * 100) / 100,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Calculate FHA MIP (Mortgage Insurance Premium)
|
|
232
|
+
*/
|
|
233
|
+
export function calculateFhaMip(
|
|
234
|
+
loanAmount: number,
|
|
235
|
+
ltv: number,
|
|
236
|
+
termYears: number
|
|
237
|
+
): { upfront: number; monthly: number; annualRate: number } {
|
|
238
|
+
// Upfront MIP: 1.75% of loan amount
|
|
239
|
+
const upfront = loanAmount * 0.0175;
|
|
240
|
+
|
|
241
|
+
// Annual MIP rates (as of 2024, may change)
|
|
242
|
+
// For loans > 15 years and LTV > 90%: 0.85%
|
|
243
|
+
// For loans > 15 years and LTV <= 90%: 0.80%
|
|
244
|
+
// For loans <= 15 years: 0.45% to 0.70% depending on LTV
|
|
245
|
+
let annualRate: number;
|
|
246
|
+
|
|
247
|
+
if (termYears > 15) {
|
|
248
|
+
annualRate = ltv > 90 ? 0.85 : 0.80;
|
|
249
|
+
} else {
|
|
250
|
+
annualRate = ltv > 90 ? 0.70 : 0.45;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const monthly = (loanAmount * annualRate / 100) / 12;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
upfront: Math.round(upfront * 100) / 100,
|
|
257
|
+
monthly: Math.round(monthly * 100) / 100,
|
|
258
|
+
annualRate,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Calculate VA Funding Fee
|
|
264
|
+
*/
|
|
265
|
+
export function calculateVaFundingFee(
|
|
266
|
+
loanAmount: number,
|
|
267
|
+
downPaymentPercent: number,
|
|
268
|
+
firstTimeUse: boolean,
|
|
269
|
+
reservist: boolean = false
|
|
270
|
+
): number {
|
|
271
|
+
// VA Funding Fee rates (2024)
|
|
272
|
+
// First use, no down: 2.15% (2.40% for reserves/NG)
|
|
273
|
+
// First use, 5-10% down: 1.50% (1.75% for reserves/NG)
|
|
274
|
+
// First use, 10%+ down: 1.25% (1.50% for reserves/NG)
|
|
275
|
+
// Subsequent use, no down: 3.30%
|
|
276
|
+
// Subsequent use, 5-10% down: 1.50%
|
|
277
|
+
// Subsequent use, 10%+ down: 1.25%
|
|
278
|
+
|
|
279
|
+
let rate: number;
|
|
280
|
+
|
|
281
|
+
if (firstTimeUse) {
|
|
282
|
+
if (downPaymentPercent >= 10) {
|
|
283
|
+
rate = reservist ? 1.50 : 1.25;
|
|
284
|
+
} else if (downPaymentPercent >= 5) {
|
|
285
|
+
rate = reservist ? 1.75 : 1.50;
|
|
286
|
+
} else {
|
|
287
|
+
rate = reservist ? 2.40 : 2.15;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
if (downPaymentPercent >= 10) {
|
|
291
|
+
rate = 1.25;
|
|
292
|
+
} else if (downPaymentPercent >= 5) {
|
|
293
|
+
rate = 1.50;
|
|
294
|
+
} else {
|
|
295
|
+
rate = 3.30;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return Math.round((loanAmount * rate / 100) * 100) / 100;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Calculate USDA Guarantee Fee
|
|
304
|
+
*/
|
|
305
|
+
export function calculateUsdaFee(loanAmount: number): { upfront: number; annual: number } {
|
|
306
|
+
// USDA fees (2024)
|
|
307
|
+
// Upfront: 1.0%
|
|
308
|
+
// Annual: 0.35%
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
upfront: Math.round((loanAmount * 0.01) * 100) / 100,
|
|
312
|
+
annual: Math.round((loanAmount * 0.0035) * 100) / 100,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loan program eligibility checking
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
LoanScenario,
|
|
7
|
+
EligibilityResult,
|
|
8
|
+
LoanProgram,
|
|
9
|
+
OccupancyType,
|
|
10
|
+
} from '../types';
|
|
11
|
+
import { getConformingLimit, LOAN_LIMITS_2026 } from './calculations';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check conventional loan eligibility
|
|
15
|
+
*/
|
|
16
|
+
export function checkConventionalEligibility(scenario: LoanScenario): EligibilityResult {
|
|
17
|
+
const reasons: string[] = [];
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
let eligible = true;
|
|
20
|
+
|
|
21
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
22
|
+
const conformingLimit = getConformingLimit(
|
|
23
|
+
scenario.county ? `${scenario.county}, ${scenario.state}` : undefined,
|
|
24
|
+
scenario.units || 1
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Credit score check
|
|
28
|
+
if (scenario.creditScore < 620) {
|
|
29
|
+
eligible = false;
|
|
30
|
+
reasons.push('Credit score below 620 minimum');
|
|
31
|
+
} else if (scenario.creditScore < 680) {
|
|
32
|
+
warnings.push('Credit score below 680 may result in pricing adjustments');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// LTV check
|
|
36
|
+
if (ltv > 97) {
|
|
37
|
+
eligible = false;
|
|
38
|
+
reasons.push('LTV exceeds 97% maximum');
|
|
39
|
+
} else if (ltv > 95) {
|
|
40
|
+
warnings.push('LTV >95% requires additional eligibility criteria');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Loan amount check (conforming vs jumbo)
|
|
44
|
+
if (scenario.loanAmount > conformingLimit) {
|
|
45
|
+
warnings.push(`Loan exceeds conforming limit ($${conformingLimit.toLocaleString()}), jumbo pricing applies`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Occupancy check for high LTV
|
|
49
|
+
if (ltv > 90 && scenario.occupancy !== 'primary') {
|
|
50
|
+
eligible = false;
|
|
51
|
+
reasons.push('LTV >90% only available for primary residence');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Investment property restrictions
|
|
55
|
+
if (scenario.occupancy === 'investment') {
|
|
56
|
+
if (ltv > 85) {
|
|
57
|
+
eligible = false;
|
|
58
|
+
reasons.push('Investment property maximum LTV is 85%');
|
|
59
|
+
}
|
|
60
|
+
if (scenario.creditScore < 680) {
|
|
61
|
+
eligible = false;
|
|
62
|
+
reasons.push('Investment property requires 680+ credit score');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Property type restrictions
|
|
67
|
+
if (scenario.propertyType === 'manufactured' && ltv > 95) {
|
|
68
|
+
eligible = false;
|
|
69
|
+
reasons.push('Manufactured homes limited to 95% LTV');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Calculate min down payment
|
|
73
|
+
let minDownPct = scenario.occupancy === 'investment' ? 15 : 3;
|
|
74
|
+
if (scenario.occupancy === 'second-home') minDownPct = 10;
|
|
75
|
+
const minDownPayment = scenario.propertyValue * (minDownPct / 100);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
program: 'conventional',
|
|
79
|
+
eligible,
|
|
80
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
81
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
82
|
+
maxLoanAmount: conformingLimit,
|
|
83
|
+
minDownPayment,
|
|
84
|
+
rateAdjustment: scenario.creditScore < 740 ? 0.25 : 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check FHA loan eligibility
|
|
90
|
+
*/
|
|
91
|
+
export function checkFhaEligibility(scenario: LoanScenario): EligibilityResult {
|
|
92
|
+
const reasons: string[] = [];
|
|
93
|
+
const warnings: string[] = [];
|
|
94
|
+
let eligible = true;
|
|
95
|
+
|
|
96
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
97
|
+
const fhaLimit = LOAN_LIMITS_2026.fhaCeiling; // Would be county-specific in production
|
|
98
|
+
|
|
99
|
+
// Credit score check
|
|
100
|
+
if (scenario.creditScore < 500) {
|
|
101
|
+
eligible = false;
|
|
102
|
+
reasons.push('Credit score below 500 minimum');
|
|
103
|
+
} else if (scenario.creditScore < 580) {
|
|
104
|
+
if (ltv > 90) {
|
|
105
|
+
eligible = false;
|
|
106
|
+
reasons.push('Credit score 500-579 requires 10% down payment (90% LTV max)');
|
|
107
|
+
}
|
|
108
|
+
warnings.push('Credit score 500-579 requires manual underwriting');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// LTV check
|
|
112
|
+
if (scenario.creditScore >= 580 && ltv > 96.5) {
|
|
113
|
+
eligible = false;
|
|
114
|
+
reasons.push('FHA maximum LTV is 96.5%');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Occupancy - FHA is owner-occupied only
|
|
118
|
+
if (scenario.occupancy !== 'primary') {
|
|
119
|
+
eligible = false;
|
|
120
|
+
reasons.push('FHA loans require owner occupancy');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Loan amount check
|
|
124
|
+
if (scenario.loanAmount > fhaLimit) {
|
|
125
|
+
eligible = false;
|
|
126
|
+
reasons.push(`Loan exceeds FHA limit ($${fhaLimit.toLocaleString()})`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Property type restrictions
|
|
130
|
+
if (scenario.propertyType === 'coop') {
|
|
131
|
+
warnings.push('Co-ops require FHA approval');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Calculate min down payment
|
|
135
|
+
const minDownPct = scenario.creditScore >= 580 ? 3.5 : 10;
|
|
136
|
+
const minDownPayment = scenario.propertyValue * (minDownPct / 100);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
program: 'fha',
|
|
140
|
+
eligible,
|
|
141
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
142
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
143
|
+
maxLoanAmount: fhaLimit,
|
|
144
|
+
minDownPayment,
|
|
145
|
+
rateAdjustment: 0, // FHA rates are generally competitive
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check VA loan eligibility
|
|
151
|
+
*/
|
|
152
|
+
export function checkVaEligibility(scenario: LoanScenario): EligibilityResult {
|
|
153
|
+
const reasons: string[] = [];
|
|
154
|
+
const warnings: string[] = [];
|
|
155
|
+
let eligible = true;
|
|
156
|
+
|
|
157
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
158
|
+
|
|
159
|
+
// Veteran status required
|
|
160
|
+
if (!scenario.veteran) {
|
|
161
|
+
eligible = false;
|
|
162
|
+
reasons.push('VA loans require veteran/military eligibility');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Credit score - VA has no official minimum but lenders typically require 580-620
|
|
166
|
+
if (scenario.creditScore < 580) {
|
|
167
|
+
warnings.push('Most VA lenders require 580+ credit score');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// LTV - VA allows 100% financing
|
|
171
|
+
if (ltv > 100) {
|
|
172
|
+
eligible = false;
|
|
173
|
+
reasons.push('VA maximum LTV is 100%');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Occupancy - VA is owner-occupied only
|
|
177
|
+
if (scenario.occupancy !== 'primary') {
|
|
178
|
+
eligible = false;
|
|
179
|
+
reasons.push('VA loans require owner occupancy');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No loan limit for full entitlement (post-2020)
|
|
183
|
+
// But there are limits for partial entitlement
|
|
184
|
+
|
|
185
|
+
// Property type restrictions
|
|
186
|
+
if (scenario.propertyType === 'coop') {
|
|
187
|
+
eligible = false;
|
|
188
|
+
reasons.push('VA does not allow co-op properties');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
program: 'va',
|
|
193
|
+
eligible,
|
|
194
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
195
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
196
|
+
minDownPayment: 0, // VA allows 0% down
|
|
197
|
+
rateAdjustment: -0.25, // VA rates typically lower
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check USDA loan eligibility
|
|
203
|
+
*/
|
|
204
|
+
export function checkUsdaEligibility(scenario: LoanScenario): EligibilityResult {
|
|
205
|
+
const reasons: string[] = [];
|
|
206
|
+
const warnings: string[] = [];
|
|
207
|
+
let eligible = true;
|
|
208
|
+
|
|
209
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
210
|
+
|
|
211
|
+
// Rural location required
|
|
212
|
+
if (!scenario.rural) {
|
|
213
|
+
eligible = false;
|
|
214
|
+
reasons.push('USDA loans require rural/eligible suburban location');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Credit score
|
|
218
|
+
if (scenario.creditScore < 640) {
|
|
219
|
+
warnings.push('Credit score below 640 requires manual underwriting');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// LTV - USDA allows 100% financing
|
|
223
|
+
if (ltv > 100) {
|
|
224
|
+
eligible = false;
|
|
225
|
+
reasons.push('USDA maximum LTV is 100%');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Occupancy - owner-occupied only
|
|
229
|
+
if (scenario.occupancy !== 'primary') {
|
|
230
|
+
eligible = false;
|
|
231
|
+
reasons.push('USDA loans require owner occupancy');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Property type - single family only
|
|
235
|
+
if (scenario.units && scenario.units > 1) {
|
|
236
|
+
eligible = false;
|
|
237
|
+
reasons.push('USDA loans are for single-family homes only');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Income limits would apply (not modeled here)
|
|
241
|
+
warnings.push('USDA has household income limits - verify eligibility');
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
program: 'usda',
|
|
245
|
+
eligible,
|
|
246
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
247
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
248
|
+
minDownPayment: 0,
|
|
249
|
+
rateAdjustment: 0,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check jumbo loan eligibility
|
|
255
|
+
*/
|
|
256
|
+
export function checkJumboEligibility(scenario: LoanScenario): EligibilityResult {
|
|
257
|
+
const reasons: string[] = [];
|
|
258
|
+
const warnings: string[] = [];
|
|
259
|
+
let eligible = true;
|
|
260
|
+
|
|
261
|
+
const ltv = (scenario.loanAmount / scenario.propertyValue) * 100;
|
|
262
|
+
const conformingLimit = getConformingLimit(
|
|
263
|
+
scenario.county ? `${scenario.county}, ${scenario.state}` : undefined,
|
|
264
|
+
scenario.units || 1
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Check if actually jumbo
|
|
268
|
+
if (scenario.loanAmount <= conformingLimit) {
|
|
269
|
+
eligible = false;
|
|
270
|
+
reasons.push('Loan amount is within conforming limits - use conventional');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Credit score - jumbo typically requires higher scores
|
|
274
|
+
if (scenario.creditScore < 700) {
|
|
275
|
+
eligible = false;
|
|
276
|
+
reasons.push('Jumbo loans typically require 700+ credit score');
|
|
277
|
+
} else if (scenario.creditScore < 720) {
|
|
278
|
+
warnings.push('Credit score below 720 may limit options');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// LTV - jumbo typically more restrictive
|
|
282
|
+
const maxLtv = scenario.occupancy === 'primary' ? 90 : 80;
|
|
283
|
+
if (ltv > maxLtv) {
|
|
284
|
+
eligible = false;
|
|
285
|
+
reasons.push(`Jumbo maximum LTV is ${maxLtv}% for ${scenario.occupancy}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Reserves typically required
|
|
289
|
+
warnings.push('Jumbo loans typically require 6-12 months reserves');
|
|
290
|
+
|
|
291
|
+
// Min down payment
|
|
292
|
+
const minDownPct = scenario.occupancy === 'primary' ? 10 : 20;
|
|
293
|
+
const minDownPayment = scenario.propertyValue * (minDownPct / 100);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
program: 'jumbo',
|
|
297
|
+
eligible,
|
|
298
|
+
reasons: reasons.length > 0 ? reasons : undefined,
|
|
299
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
300
|
+
minDownPayment,
|
|
301
|
+
rateAdjustment: 0.25, // Jumbo typically slightly higher
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check all program eligibility
|
|
307
|
+
*/
|
|
308
|
+
export function checkAllEligibility(scenario: LoanScenario): EligibilityResult[] {
|
|
309
|
+
return [
|
|
310
|
+
checkConventionalEligibility(scenario),
|
|
311
|
+
checkFhaEligibility(scenario),
|
|
312
|
+
checkVaEligibility(scenario),
|
|
313
|
+
checkUsdaEligibility(scenario),
|
|
314
|
+
checkJumboEligibility(scenario),
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Find best recommended program
|
|
320
|
+
*/
|
|
321
|
+
export function findBestProgram(
|
|
322
|
+
eligibility: EligibilityResult[]
|
|
323
|
+
): LoanProgram | undefined {
|
|
324
|
+
const eligible = eligibility.filter(e => e.eligible);
|
|
325
|
+
if (eligible.length === 0) return undefined;
|
|
326
|
+
|
|
327
|
+
// Priority order: VA (if eligible), then lowest down payment, then best rate
|
|
328
|
+
const va = eligible.find(e => e.program === 'va');
|
|
329
|
+
if (va) return 'va';
|
|
330
|
+
|
|
331
|
+
const usda = eligible.find(e => e.program === 'usda');
|
|
332
|
+
if (usda) return 'usda';
|
|
333
|
+
|
|
334
|
+
// Sort by min down payment, then rate adjustment
|
|
335
|
+
eligible.sort((a, b) => {
|
|
336
|
+
const downA = a.minDownPayment || 0;
|
|
337
|
+
const downB = b.minDownPayment || 0;
|
|
338
|
+
if (downA !== downB) return downA - downB;
|
|
339
|
+
return (a.rateAdjustment || 0) - (b.rateAdjustment || 0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return eligible[0].program;
|
|
343
|
+
}
|