@@ -11,7 +11,7 @@ | |||
</div> | |||
<home :user="user" v-else-if="active == 1" /> | |||
<new-estimate :user="user" :fees="fees" v-else-if="active == 2" /> | |||
<new-estimate :user="user" :fees="fees" :token="token" v-else-if="active == 2" /> | |||
<estimates :user="user" :fees="fees" v-else-if="active == 3" /> | |||
<settings :user="user" v-else-if="active == 4" /> | |||
<sign-out :user="user" v-else-if="active == 5" /> | |||
@@ -43,6 +43,7 @@ function getCookie(name) { | |||
function refreshToken() { | |||
const token = getCookie("skouter") | |||
this.token = token | |||
fetch(`/api/token`, | |||
{method: 'GET', | |||
@@ -62,6 +63,7 @@ function refreshToken() { | |||
function getUser() { | |||
const token = getCookie("skouter") | |||
this.token = token | |||
return fetch(`/api/user`, | |||
{method: 'GET', | |||
@@ -101,20 +103,6 @@ function getFees() { | |||
} | |||
// The default fees of a new loan. Percentage values take precedent over amounts | |||
const fees = [ | |||
{ name: 'Processing fee', type: 'Lender Fees', amount: 500, perc: 0 }, | |||
{ name: 'Underwriting fee', type: 'Lender Fees', amount: 500, perc: 0 }, | |||
{ name: 'Credit Report', type: 'Services Required by Lender', | |||
amount: 52.50 }, | |||
{ name: 'Appraisal', type: 'Services Required by Lender', amount: 52.50 }, | |||
{ name: 'Title Services', type: 'Title Company', amount: 1000 }, | |||
{ name: 'Lender\'s Title Insurance', type: 'Title Company', amount: 1599 }, | |||
{ name: 'Owner\'s Title Insurance', type: 'Title Company', amount: 451.00 }, | |||
{ name: 'Recording Charges', type: 'Government', amount: 99.00 }, | |||
{ name: 'State Tax', type: 'Government', amount: 2411.00 }, | |||
] | |||
// Used to check the current section of the app generally without a regex match | |||
// each time. | |||
function active() { | |||
@@ -183,6 +171,7 @@ export default { | |||
hash: window.location.hash, | |||
fees: [], | |||
loadingError: "", | |||
token: '', | |||
} | |||
}, | |||
created() { | |||
@@ -3,7 +3,7 @@ | |||
<section class="form inputs"> | |||
<h3>Loan</h3> | |||
<label>Name</label> | |||
<input :value="loans[sel].title" required | |||
<input :value="loan.title" required | |||
@input="(e) => $emit('update:name', stripLetters(e))"> | |||
<button @click="() => $emit('del')">Delete</button> | |||
</section> | |||
@@ -71,19 +71,19 @@ | |||
<input type="radio" | |||
name="loan_type" | |||
value="fha" | |||
:checked="loans[sel].type == 'fha'" | |||
:checked="loan.type == 'fha'" | |||
@change="(e) => $emit('update:loanType', e.target.value)"> | |||
<label>FHA</label> | |||
<input type="radio" | |||
name="loan_type" | |||
value="va" | |||
:checked="loans[sel].type == 'va'" | |||
:checked="loan.type == 'va'" | |||
@change="(e) => $emit('update:loanType', e.target.value)"> | |||
<label>VA</label> | |||
<input type="radio" | |||
name="loan_type" | |||
value="usda" | |||
:checked="loans[sel].type == 'usda'" | |||
:checked="loan.type == 'usda'" | |||
@change="(e) => $emit('update:loanType', e.target.value)"> | |||
<label>USDA</label> | |||
</section> | |||
@@ -91,12 +91,12 @@ | |||
<section class="form inputs"> | |||
<h3>Loan Details</h3> | |||
<label>Loan Term (years)</label> | |||
<input :value="loans[sel].term" | |||
<input :value="loan.term" | |||
@input="(e) => $emit('update:term', stripInt(e))"> | |||
<label>Loan Program</label> | |||
<select id="" name="" | |||
:value="loans[sel].program" | |||
:value="loan.program" | |||
@change="(e) => $emit('update:program', e.target.value)"> | |||
<option value="none">None</option> | |||
</select> | |||
@@ -107,39 +107,39 @@ | |||
<input :value="loan.amount" | |||
@input="(e) => $emit('update:amount', e)"> | |||
<label>Housing Expense DTI (%) - Optional</label> | |||
<input :value="loans[sel].housingDti" | |||
<input :value="loan.housingDti" | |||
@input="(e) => $emit('update:housingDti', e)"> | |||
<label>Total DTI (%) - Optional</label> | |||
<input :value="loans[sel].dti" @input="(e) => $emit('update:dti', e)"> | |||
<input :value="loan.dti" @input="(e) => $emit('update:dti', e)"> | |||
<label>Home Owner's Association ($/month)</label> | |||
<input :value="loans[sel].hoa" | |||
<input :value="loan.hoa" | |||
@input="(e) => { $emit('update:hoa', strip(e)) }"> | |||
<label>Interest Rate (%)</label> | |||
<input :value="loans[sel].interest" | |||
<input :value="loan.interest" | |||
@input="(e) => { $emit('update:interest', stripPerc(e)) }"> | |||
<label>Days of Interest</label> | |||
<input :value="loans[sel].interestDays" | |||
<input :value="loan.interestDays" | |||
@input="(e) => $emit('update:interestDays', stripInt(e))"> | |||
<label>Hazard Insurance Escrow (months)</label> | |||
<input :value="loans[sel].hazardEscrow" | |||
<input :value="loan.hazardEscrow" | |||
@input="(e) => { $emit('update:hazardEscrow', stripInt(e)) }"> | |||
<label>Hazard Insurance ($/month)</label> | |||
<input :value="loans[sel].hazard" | |||
<input :value="loan.hazard" | |||
@input="(e) => $emit('update:hazard', strip(e))"> | |||
<label>Real Estate Tax Escrow (months)</label> | |||
<input :value="loans[sel].taxEscrow" | |||
<input :value="loan.taxEscrow" | |||
@input="e => $emit('update:taxEscrow', stripInt(e))"> | |||
<label>Real Estate Tax ($/month)</label> | |||
<input :value="loans[sel].tax" | |||
<input :value="loan.tax" | |||
@input="(e) => $emit('update:tax', strip(e))"> | |||
</section> | |||
<section class="form inputs"> | |||
<h3>Fees</h3> | |||
<div v-for="(fee, indx) in estimate.loans[sel].fees" | |||
<div v-for="(fee, indx) in loan.fees" | |||
:key="fee.name + indx" class="fee" | |||
> | |||
<label> | |||
@@ -147,7 +147,7 @@ | |||
{{fee.type}} | |||
</label> | |||
<img width="21" height="21" src="/assets/image/icon/x-red.svg" | |||
@click="() => estimate.loans[sel].fees.splice(indx, 1)"> | |||
@click="() => loan.fees.splice(indx, 1)"> | |||
</div> | |||
<button @click="resetFees">Reset</button> | |||
<button @click="createFee">New</button> | |||
@@ -165,18 +165,18 @@ | |||
<h3>Mortgage Insurance</h3> | |||
<div class="row"> | |||
<input checked type="radio" name="mi"/><label>Manual %</label> | |||
<input type="checkbox" :checked="loans[sel].mi.monthly" | |||
<input type="checkbox" :checked="loan.mi.monthly" | |||
@change="e => $emit('toggle:manualMIMonthly')" /> | |||
<label>monthly</label> | |||
</div> | |||
<div class="row"> | |||
<input :value="loans[sel].mi.rate" @input="e => $emit('update:manualMI', stripPerc(e))" /> | |||
<input :value="loan.mi.rate" @input="e => $emit('update:manualMI', stripPerc(e))" /> | |||
</div> | |||
</section> | |||
<section class="form inputs"> | |||
<button @click="() => validate() && $emit('continue')">Continue</button> | |||
<button @click="validate">Continue</button> | |||
<ul class="errors"> | |||
<li v-for="e in errors">{{e}}</li> | |||
@@ -205,12 +205,25 @@ function createFee() { | |||
function addFee(fee, isDebit) { | |||
if (!isDebit) fee.amount = fee.amount * -1 | |||
this.estimate.loans[this.sel].fees.push(fee) | |||
this.loan.fees.push(fee) | |||
this.newFee = null | |||
} | |||
function validate() { | |||
fetch(`/api/estimate/validate`, | |||
{method: 'POST', | |||
body: JSON.stringify(this.estimate), | |||
headers: { | |||
"Accept": "application/json", | |||
"Authorization": `Bearer ${this.token}`, | |||
}, | |||
}).then(response => { | |||
if (response.ok) { return response.json() } | |||
response.text().then(t => this.errors = [t]) | |||
}).then(result => { | |||
if (result) this.$emit('continue', result) | |||
}) | |||
/* | |||
let errors = [] | |||
const estimate = this.estimate | |||
@@ -252,6 +265,7 @@ function validate() { | |||
} | |||
return true | |||
*/ | |||
} | |||
function generate() { | |||
@@ -268,7 +282,7 @@ export default { | |||
strip, stripInt, stripLetters, stripPerc, createFee, addFee, validate, | |||
generate | |||
}, | |||
props: ['estimate', 'loans', 'sel', 'loan'], | |||
props: ['estimate', 'loan', 'token'], | |||
// Loan updates assume the currently selected loan is being modified, and | |||
// $emit has no need to clarify. | |||
emits: [ | |||
@@ -25,6 +25,7 @@ class="bi bi-plus" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 | |||
:loans="estimate.loans" | |||
:sel="sel" | |||
:loan="loan" | |||
:token="token" | |||
@update:name="(name) => loans[sel].title = name" | |||
@del="del" | |||
@@ -85,7 +86,7 @@ const example = { | |||
pud: true, // Property under development | |||
zip: '', | |||
fees: [], | |||
mi: {monthly: false, rate: 0} | |||
mi: { monthly: false, rate: 0 } | |||
} | |||
// The default loans on a new estimate | |||
@@ -192,7 +193,7 @@ function setHousingDti(e) { | |||
} | |||
function generate() { | |||
window.location.hash = 'new/summary' | |||
window.location.hash = 'new/summary' | |||
} | |||
// Percentage values of fees always take precedent over amounts. The conversion | |||
@@ -204,7 +205,7 @@ export default { | |||
setDti, setHousingDti | |||
}, | |||
computed: { loan }, | |||
props: ['user', 'fees'], | |||
props: ['user', 'fees', 'token'], | |||
data() { | |||
return { | |||
estimate: estimate, | |||
@@ -216,7 +217,8 @@ export default { | |||
}, | |||
created() { | |||
this.estimate.loans.forEach(l => l.fees = this.createFees()) | |||
window.addEventListener("hashchange", () => this.hash = window.location.hash) | |||
window.addEventListener("hashchange", | |||
() => this.hash = window.location.hash) | |||
} | |||
} | |||
</script> |
@@ -130,7 +130,7 @@ type Estimate struct { | |||
Id int `json:"id"` | |||
User int `json:"user"` | |||
Borrower Borrower `json:"borrower"` | |||
Transaction string `json:"transaction"` | |||
Transaction int `json:"transaction"` | |||
Price int `json:"price"` | |||
Property string `json:"property"` | |||
Occupancy string `json:"occupancy"` | |||
@@ -499,9 +499,9 @@ func fetchMi(db *sql.DB, estimate *Estimate, pos int) []MI { | |||
"Condo Hi-rise": "CON", | |||
} | |||
var purposeCodes = map[string]string { | |||
"Purchase": "PUR", | |||
"Refinance": "RRT", | |||
var purposeCodes = map[int]string { | |||
1: "PUR", | |||
2: "RRT", | |||
} | |||
body, err := json.Marshal(map[string]any{ | |||
@@ -632,10 +632,6 @@ func getToken(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||
http.StatusInternalServerError)} | |||
} | |||
func validateEstimate() { | |||
return | |||
} | |||
func getClaims(r *http.Request) (UserClaims, error) { | |||
claims := new(UserClaims) | |||
_, tokenStr, found := strings.Cut(r.Header.Get("Authorization"), " ") | |||
@@ -886,11 +882,104 @@ func createEstimate(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||
err := json.NewDecoder(r.Body).Decode(&estimate) | |||
if err != nil { http.Error(w, "Invalid fields.", 422); return } | |||
if err != nil { http.Error(w, "Error creating user.", 422); return } | |||
json.NewEncoder(w).Encode(estimate) | |||
} | |||
func checkConventional(l Loan, b Borrower) error { | |||
if b.Credit < 620 { | |||
return errors.New("Credit score too low for conventional loan") | |||
} | |||
// Buyer needs to put down 20% to avoid mortgage insurance | |||
if (l.Ltv > 80 && l.Mi.Rate == 0) { | |||
return errors.New(fmt.Sprintln( | |||
l.Name, | |||
"down payment must be 20% to avoid insurance", | |||
)) | |||
} | |||
return nil | |||
} | |||
func checkFHA(l Loan, b Borrower) error { | |||
if b.Credit < 500 { | |||
return errors.New("Credit score too low for FHA loan") | |||
} | |||
if (l.Ltv > 96.5) { | |||
return errors.New("FHA down payment must be at least 3.5%") | |||
} | |||
if (b.Credit < 580 && l.Ltv > 90) { | |||
return errors.New("FHA down payment must be at least 10%") | |||
} | |||
// Debt-to-income must be below 45% if credit score is below 580. | |||
if (b.Credit < 580 && l.Dti > 45) { | |||
return errors.New(fmt.Sprintln( | |||
l.Name, "debt to income is too high for credit score.", | |||
)) | |||
} | |||
return nil | |||
} | |||
// Should also check loan amount limit maybe with an API. | |||
func checkEstimate(e Estimate) error { | |||
if e.Property == "" { return errors.New("Empty property type") } | |||
if e.Price == 0 { return errors.New("Empty property price") } | |||
if e.Borrower.Num == 0 { | |||
return errors.New("Missing number of borrowers") | |||
} | |||
if e.Borrower.Credit == 0 { | |||
return errors.New("Missing borrower credit score") | |||
} | |||
if e.Borrower.Income == 0 { | |||
return errors.New("Missing borrower credit income") | |||
} | |||
for _, l := range e.Loans { | |||
if l.Amount == 0 { | |||
return errors.New(fmt.Sprintln(l.Name, "loan amount cannot be zero")) | |||
} | |||
if l.Term == 0 { | |||
return errors.New(fmt.Sprintln(l.Name, "loan term cannot be zero")) | |||
} | |||
if l.Interest == 0 { | |||
return errors.New(fmt.Sprintln(l.Name, "loan interest cannot be zero")) | |||
} | |||
// Can be used to check rules for specific loan types | |||
var err error | |||
switch l.Type.Name { | |||
case "Conventional": | |||
err = checkConventional(l, e.Borrower) | |||
case "FHA": | |||
err = checkFHA(l, e.Borrower) | |||
case "VA": | |||
err = checkConventional(l, e.Borrower) | |||
case "USDA": | |||
err = checkConventional(l, e.Borrower) | |||
} | |||
if err != nil { return err } | |||
} | |||
println("done") | |||
return nil | |||
} | |||
func validateEstimate(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||
var estimate Estimate | |||
err := json.NewDecoder(r.Body).Decode(&estimate) | |||
if err != nil { http.Error(w, "Invalid fields.", 422); return } | |||
err = checkEstimate(estimate) | |||
if err != nil { http.Error(w, err.Error(), 406); return } | |||
} | |||
func api(w http.ResponseWriter, r *http.Request) { | |||
var args []string | |||
@@ -946,6 +1035,10 @@ func api(w http.ResponseWriter, r *http.Request) { | |||
r.Method == http.MethodPost && | |||
guard(r, 1): | |||
createEstimate(w, db, r) | |||
case match(p, "/api/estimate/validate", &args) && | |||
r.Method == http.MethodPost && | |||
guard(r, 1): | |||
validateEstimate(w, db, r) | |||
} | |||
db.Close() | |||