@@ -11,7 +11,7 @@ | |||||
</div> | </div> | ||||
<home :user="user" v-else-if="active == 1" /> | <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" /> | <estimates :user="user" :fees="fees" v-else-if="active == 3" /> | ||||
<settings :user="user" v-else-if="active == 4" /> | <settings :user="user" v-else-if="active == 4" /> | ||||
<sign-out :user="user" v-else-if="active == 5" /> | <sign-out :user="user" v-else-if="active == 5" /> | ||||
@@ -43,6 +43,7 @@ function getCookie(name) { | |||||
function refreshToken() { | function refreshToken() { | ||||
const token = getCookie("skouter") | const token = getCookie("skouter") | ||||
this.token = token | |||||
fetch(`/api/token`, | fetch(`/api/token`, | ||||
{method: 'GET', | {method: 'GET', | ||||
@@ -62,6 +63,7 @@ function refreshToken() { | |||||
function getUser() { | function getUser() { | ||||
const token = getCookie("skouter") | const token = getCookie("skouter") | ||||
this.token = token | |||||
return fetch(`/api/user`, | return fetch(`/api/user`, | ||||
{method: 'GET', | {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 | // Used to check the current section of the app generally without a regex match | ||||
// each time. | // each time. | ||||
function active() { | function active() { | ||||
@@ -183,6 +171,7 @@ export default { | |||||
hash: window.location.hash, | hash: window.location.hash, | ||||
fees: [], | fees: [], | ||||
loadingError: "", | loadingError: "", | ||||
token: '', | |||||
} | } | ||||
}, | }, | ||||
created() { | created() { | ||||
@@ -3,7 +3,7 @@ | |||||
<section class="form inputs"> | <section class="form inputs"> | ||||
<h3>Loan</h3> | <h3>Loan</h3> | ||||
<label>Name</label> | <label>Name</label> | ||||
<input :value="loans[sel].title" required | |||||
<input :value="loan.title" required | |||||
@input="(e) => $emit('update:name', stripLetters(e))"> | @input="(e) => $emit('update:name', stripLetters(e))"> | ||||
<button @click="() => $emit('del')">Delete</button> | <button @click="() => $emit('del')">Delete</button> | ||||
</section> | </section> | ||||
@@ -71,19 +71,19 @@ | |||||
<input type="radio" | <input type="radio" | ||||
name="loan_type" | name="loan_type" | ||||
value="fha" | value="fha" | ||||
:checked="loans[sel].type == 'fha'" | |||||
:checked="loan.type == 'fha'" | |||||
@change="(e) => $emit('update:loanType', e.target.value)"> | @change="(e) => $emit('update:loanType', e.target.value)"> | ||||
<label>FHA</label> | <label>FHA</label> | ||||
<input type="radio" | <input type="radio" | ||||
name="loan_type" | name="loan_type" | ||||
value="va" | value="va" | ||||
:checked="loans[sel].type == 'va'" | |||||
:checked="loan.type == 'va'" | |||||
@change="(e) => $emit('update:loanType', e.target.value)"> | @change="(e) => $emit('update:loanType', e.target.value)"> | ||||
<label>VA</label> | <label>VA</label> | ||||
<input type="radio" | <input type="radio" | ||||
name="loan_type" | name="loan_type" | ||||
value="usda" | value="usda" | ||||
:checked="loans[sel].type == 'usda'" | |||||
:checked="loan.type == 'usda'" | |||||
@change="(e) => $emit('update:loanType', e.target.value)"> | @change="(e) => $emit('update:loanType', e.target.value)"> | ||||
<label>USDA</label> | <label>USDA</label> | ||||
</section> | </section> | ||||
@@ -91,12 +91,12 @@ | |||||
<section class="form inputs"> | <section class="form inputs"> | ||||
<h3>Loan Details</h3> | <h3>Loan Details</h3> | ||||
<label>Loan Term (years)</label> | <label>Loan Term (years)</label> | ||||
<input :value="loans[sel].term" | |||||
<input :value="loan.term" | |||||
@input="(e) => $emit('update:term', stripInt(e))"> | @input="(e) => $emit('update:term', stripInt(e))"> | ||||
<label>Loan Program</label> | <label>Loan Program</label> | ||||
<select id="" name="" | <select id="" name="" | ||||
:value="loans[sel].program" | |||||
:value="loan.program" | |||||
@change="(e) => $emit('update:program', e.target.value)"> | @change="(e) => $emit('update:program', e.target.value)"> | ||||
<option value="none">None</option> | <option value="none">None</option> | ||||
</select> | </select> | ||||
@@ -107,39 +107,39 @@ | |||||
<input :value="loan.amount" | <input :value="loan.amount" | ||||
@input="(e) => $emit('update:amount', e)"> | @input="(e) => $emit('update:amount', e)"> | ||||
<label>Housing Expense DTI (%) - Optional</label> | <label>Housing Expense DTI (%) - Optional</label> | ||||
<input :value="loans[sel].housingDti" | |||||
<input :value="loan.housingDti" | |||||
@input="(e) => $emit('update:housingDti', e)"> | @input="(e) => $emit('update:housingDti', e)"> | ||||
<label>Total DTI (%) - Optional</label> | <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> | <label>Home Owner's Association ($/month)</label> | ||||
<input :value="loans[sel].hoa" | |||||
<input :value="loan.hoa" | |||||
@input="(e) => { $emit('update:hoa', strip(e)) }"> | @input="(e) => { $emit('update:hoa', strip(e)) }"> | ||||
<label>Interest Rate (%)</label> | <label>Interest Rate (%)</label> | ||||
<input :value="loans[sel].interest" | |||||
<input :value="loan.interest" | |||||
@input="(e) => { $emit('update:interest', stripPerc(e)) }"> | @input="(e) => { $emit('update:interest', stripPerc(e)) }"> | ||||
<label>Days of Interest</label> | <label>Days of Interest</label> | ||||
<input :value="loans[sel].interestDays" | |||||
<input :value="loan.interestDays" | |||||
@input="(e) => $emit('update:interestDays', stripInt(e))"> | @input="(e) => $emit('update:interestDays', stripInt(e))"> | ||||
<label>Hazard Insurance Escrow (months)</label> | <label>Hazard Insurance Escrow (months)</label> | ||||
<input :value="loans[sel].hazardEscrow" | |||||
<input :value="loan.hazardEscrow" | |||||
@input="(e) => { $emit('update:hazardEscrow', stripInt(e)) }"> | @input="(e) => { $emit('update:hazardEscrow', stripInt(e)) }"> | ||||
<label>Hazard Insurance ($/month)</label> | <label>Hazard Insurance ($/month)</label> | ||||
<input :value="loans[sel].hazard" | |||||
<input :value="loan.hazard" | |||||
@input="(e) => $emit('update:hazard', strip(e))"> | @input="(e) => $emit('update:hazard', strip(e))"> | ||||
<label>Real Estate Tax Escrow (months)</label> | <label>Real Estate Tax Escrow (months)</label> | ||||
<input :value="loans[sel].taxEscrow" | |||||
<input :value="loan.taxEscrow" | |||||
@input="e => $emit('update:taxEscrow', stripInt(e))"> | @input="e => $emit('update:taxEscrow', stripInt(e))"> | ||||
<label>Real Estate Tax ($/month)</label> | <label>Real Estate Tax ($/month)</label> | ||||
<input :value="loans[sel].tax" | |||||
<input :value="loan.tax" | |||||
@input="(e) => $emit('update:tax', strip(e))"> | @input="(e) => $emit('update:tax', strip(e))"> | ||||
</section> | </section> | ||||
<section class="form inputs"> | <section class="form inputs"> | ||||
<h3>Fees</h3> | <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" | :key="fee.name + indx" class="fee" | ||||
> | > | ||||
<label> | <label> | ||||
@@ -147,7 +147,7 @@ | |||||
{{fee.type}} | {{fee.type}} | ||||
</label> | </label> | ||||
<img width="21" height="21" src="/assets/image/icon/x-red.svg" | <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> | </div> | ||||
<button @click="resetFees">Reset</button> | <button @click="resetFees">Reset</button> | ||||
<button @click="createFee">New</button> | <button @click="createFee">New</button> | ||||
@@ -165,18 +165,18 @@ | |||||
<h3>Mortgage Insurance</h3> | <h3>Mortgage Insurance</h3> | ||||
<div class="row"> | <div class="row"> | ||||
<input checked type="radio" name="mi"/><label>Manual %</label> | <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')" /> | @change="e => $emit('toggle:manualMIMonthly')" /> | ||||
<label>monthly</label> | <label>monthly</label> | ||||
</div> | </div> | ||||
<div class="row"> | <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> | </div> | ||||
</section> | </section> | ||||
<section class="form inputs"> | <section class="form inputs"> | ||||
<button @click="() => validate() && $emit('continue')">Continue</button> | |||||
<button @click="validate">Continue</button> | |||||
<ul class="errors"> | <ul class="errors"> | ||||
<li v-for="e in errors">{{e}}</li> | <li v-for="e in errors">{{e}}</li> | ||||
@@ -205,12 +205,25 @@ function createFee() { | |||||
function addFee(fee, isDebit) { | function addFee(fee, isDebit) { | ||||
if (!isDebit) fee.amount = fee.amount * -1 | if (!isDebit) fee.amount = fee.amount * -1 | ||||
this.estimate.loans[this.sel].fees.push(fee) | |||||
this.loan.fees.push(fee) | |||||
this.newFee = null | this.newFee = null | ||||
} | } | ||||
function validate() { | 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 = [] | let errors = [] | ||||
const estimate = this.estimate | const estimate = this.estimate | ||||
@@ -252,6 +265,7 @@ function validate() { | |||||
} | } | ||||
return true | return true | ||||
*/ | |||||
} | } | ||||
function generate() { | function generate() { | ||||
@@ -268,7 +282,7 @@ export default { | |||||
strip, stripInt, stripLetters, stripPerc, createFee, addFee, validate, | strip, stripInt, stripLetters, stripPerc, createFee, addFee, validate, | ||||
generate | generate | ||||
}, | }, | ||||
props: ['estimate', 'loans', 'sel', 'loan'], | |||||
props: ['estimate', 'loan', 'token'], | |||||
// Loan updates assume the currently selected loan is being modified, and | // Loan updates assume the currently selected loan is being modified, and | ||||
// $emit has no need to clarify. | // $emit has no need to clarify. | ||||
emits: [ | 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" | :loans="estimate.loans" | ||||
:sel="sel" | :sel="sel" | ||||
:loan="loan" | :loan="loan" | ||||
:token="token" | |||||
@update:name="(name) => loans[sel].title = name" | @update:name="(name) => loans[sel].title = name" | ||||
@del="del" | @del="del" | ||||
@@ -85,7 +86,7 @@ const example = { | |||||
pud: true, // Property under development | pud: true, // Property under development | ||||
zip: '', | zip: '', | ||||
fees: [], | fees: [], | ||||
mi: {monthly: false, rate: 0} | |||||
mi: { monthly: false, rate: 0 } | |||||
} | } | ||||
// The default loans on a new estimate | // The default loans on a new estimate | ||||
@@ -192,7 +193,7 @@ function setHousingDti(e) { | |||||
} | } | ||||
function generate() { | function generate() { | ||||
window.location.hash = 'new/summary' | |||||
window.location.hash = 'new/summary' | |||||
} | } | ||||
// Percentage values of fees always take precedent over amounts. The conversion | // Percentage values of fees always take precedent over amounts. The conversion | ||||
@@ -204,7 +205,7 @@ export default { | |||||
setDti, setHousingDti | setDti, setHousingDti | ||||
}, | }, | ||||
computed: { loan }, | computed: { loan }, | ||||
props: ['user', 'fees'], | |||||
props: ['user', 'fees', 'token'], | |||||
data() { | data() { | ||||
return { | return { | ||||
estimate: estimate, | estimate: estimate, | ||||
@@ -216,7 +217,8 @@ export default { | |||||
}, | }, | ||||
created() { | created() { | ||||
this.estimate.loans.forEach(l => l.fees = this.createFees()) | 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> | </script> |
@@ -130,7 +130,7 @@ type Estimate struct { | |||||
Id int `json:"id"` | Id int `json:"id"` | ||||
User int `json:"user"` | User int `json:"user"` | ||||
Borrower Borrower `json:"borrower"` | Borrower Borrower `json:"borrower"` | ||||
Transaction string `json:"transaction"` | |||||
Transaction int `json:"transaction"` | |||||
Price int `json:"price"` | Price int `json:"price"` | ||||
Property string `json:"property"` | Property string `json:"property"` | ||||
Occupancy string `json:"occupancy"` | Occupancy string `json:"occupancy"` | ||||
@@ -499,9 +499,9 @@ func fetchMi(db *sql.DB, estimate *Estimate, pos int) []MI { | |||||
"Condo Hi-rise": "CON", | "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{ | body, err := json.Marshal(map[string]any{ | ||||
@@ -632,10 +632,6 @@ func getToken(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
http.StatusInternalServerError)} | http.StatusInternalServerError)} | ||||
} | } | ||||
func validateEstimate() { | |||||
return | |||||
} | |||||
func getClaims(r *http.Request) (UserClaims, error) { | func getClaims(r *http.Request) (UserClaims, error) { | ||||
claims := new(UserClaims) | claims := new(UserClaims) | ||||
_, tokenStr, found := strings.Cut(r.Header.Get("Authorization"), " ") | _, 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) | err := json.NewDecoder(r.Body).Decode(&estimate) | ||||
if err != nil { http.Error(w, "Invalid fields.", 422); return } | 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) | 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) { | func api(w http.ResponseWriter, r *http.Request) { | ||||
var args []string | var args []string | ||||
@@ -946,6 +1035,10 @@ func api(w http.ResponseWriter, r *http.Request) { | |||||
r.Method == http.MethodPost && | r.Method == http.MethodPost && | ||||
guard(r, 1): | guard(r, 1): | ||||
createEstimate(w, db, r) | createEstimate(w, db, r) | ||||
case match(p, "/api/estimate/validate", &args) && | |||||
r.Method == http.MethodPost && | |||||
guard(r, 1): | |||||
validateEstimate(w, db, r) | |||||
} | } | ||||
db.Close() | db.Close() | ||||