Skouter mortgage estimates. Web application with view written in PHP and Vue, but controller and models in Go.
 
 
 
 
 
 

450 lines
11 KiB

  1. <template>
  2. <div id="new" class="page">
  3. <h2>New Loan</h2>
  4. <section class="loans-list">
  5. <h3 v-for="(l, indx) in loans"
  6. :class="sel == indx ? 'sel' : ''"
  7. @click="() => sel = indx"
  8. >
  9. {{l.title}}
  10. </h3>
  11. <div class="add">
  12. <svg @click="create"
  13. xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
  14. class="bi bi-plus" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0
  15. 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/> </svg>
  16. </div>
  17. </section>
  18. <section class="form inputs">
  19. <h3>Loan</h3>
  20. <label>Name</label>
  21. <input :value="loans[sel].title" required
  22. @input="(e) => loans[sel].title = stripLetters(e)">
  23. <button @click="del">Delete</button>
  24. </section>
  25. <section class="form inputs">
  26. <div class="hint">
  27. <img class="icon" src="/assets/image/icon/question-circle.svg" alt="">
  28. <div class="tooltip">
  29. <p>Assumes borrower is not self employed, not bankrupt in the past 7
  30. years, a citizen, and intends to occupy the property.</p>
  31. </div>
  32. </div>
  33. <h3>Borrower</h3>
  34. <label>Number of Borrowers</label>
  35. <input :value="estimate.borrowers"
  36. @input="(e) => estimate.borrowers = stripInt(e)">
  37. <label>Credit Score</label>
  38. <input :value="estimate.creditScore"
  39. @input="(e) => estimate.creditScore = stripInt(e)">
  40. <label>Monthly Income ($)</label>
  41. <input :value="estimate.mIncome"
  42. @input="(e) => estimate.mIncome = strip(e)">
  43. </section>
  44. <section class="radios form">
  45. <h3>Transaction Type</h3>
  46. <input selected type="radio" name="transaction_type" value="0"
  47. v-model="estimate.transaction" >
  48. <label>Purchase</label>
  49. <input type="radio" name="transaction_type" value="1"
  50. v-model="estimate.transaction">
  51. <label>Refinance</label>
  52. </section>
  53. <section class="form inputs">
  54. <h3>Property Details</h3>
  55. <label>Price ($)</label>
  56. <input :value="estimate.price" @input="setPrice">
  57. <label>Type</label>
  58. <select id="" name="" v-model="estimate.property">
  59. <option value="attched">Single Family Attached</option>
  60. <option value="detached">Single Family Detached</option>
  61. <option value="lorise">Lo-rise (4 stories or less)</option>
  62. <option value="hirise">Hi-rise (over 4 stories)</option>
  63. </select>
  64. </section>
  65. <section class="radios form">
  66. <h3>Loan Type</h3>
  67. <input type="radio" name="loan_type" value="conv" v-model="loans[sel].type"
  68. >
  69. <label>Conventional</label>
  70. <input type="radio" name="loan_type" value="fha" v-model="loans[sel].type">
  71. <label>FHA</label>
  72. <input type="radio" name="loan_type" value="va" v-model="loans[sel].type">
  73. <label>VA</label>
  74. <input type="radio" name="loan_type" value="usda" v-model="loans[sel].type">
  75. <label>USDA</label>
  76. </section>
  77. <section class="form inputs">
  78. <h3>Loan Details</h3>
  79. <label>Loan Term (years)</label>
  80. <input :value="loans[sel].term"
  81. @input="(e) => loans[sel].term = strip(e)">
  82. <label>Loan Program</label>
  83. <select id="" name="" v-model="loans[sel].program">
  84. <option value="none">None</option>
  85. </select>
  86. <label>Loan to Value (%)</label>
  87. <input :value="loans[sel].ltv" @input="setLtv">
  88. <label>Loan Amount ($)</label>
  89. <input :value="loans[sel].amount"
  90. @input="setAmount">
  91. <label>Housing Expense DTI (%) - Optional</label>
  92. <input :value="loans[sel].housingDti" @input="setHousingDti">
  93. <label>Total DTI (%) - Optional</label>
  94. <input :value="loans[sel].dti" @input="setDti">
  95. <label>Home Owner's Association ($/month)</label>
  96. <input :value="loans[sel].hoa"
  97. @input="(e) => { loans[sel].hoa = strip(e) }">
  98. <label>Interest Rate (%)</label>
  99. <input :value="loans[sel].interest"
  100. @input="(e) => { loans[sel].interest = stripPerc(e) }">
  101. <label>Days of Interest</label>
  102. <input :value="loans[sel].interestDays"
  103. @input="(e) => {loans[sel].interestDays = stripInt(e)}">
  104. <label>Hazard Insurance Escrow (months)</label>
  105. <input :value="loans[sel].hazardEscrow"
  106. @input="(e) => {loans[sel].hazardEscrow = stripInt(e)}">
  107. <label>Hazard Insurance ($/month)</label>
  108. <input :value="loans[sel].hazard"
  109. @input="(e) => {loans[sel].hazard = strip(e)}">
  110. <label>Real Estate Tax Escrow (months)</label>
  111. <input :value="loans[sel].taxEscrow"
  112. @input="e => {loans[sel].taxEscrow = stripInt(e)}">
  113. <label>Real Estate Tax ($/month)</label>
  114. <input :value="loans[sel].tax"
  115. @input="(e) => {loans[sel].tax = strip(e)}">
  116. </section>
  117. <section class="form inputs">
  118. <h3>Fees</h3>
  119. <div v-for="(fee, indx) in estimate.loans[sel].fees"
  120. :key="fee.name + indx" class="fee"
  121. >
  122. <label>
  123. ${{fee.amount}} - {{fee.name}}<br>
  124. {{fee.type}}
  125. </label>
  126. <img width="21" height="21" src="/assets/image/icon/x-red.svg"
  127. @click="() => estimate.loans[sel].fees.splice(indx, 1)">
  128. </div>
  129. <button @click="resetFees">Reset</button>
  130. <button @click="createFee">New</button>
  131. </section>
  132. <Dialog v-if="newFee" @close="() => newFee = null">
  133. <h3>New Fee</h3>
  134. <label>Name</label>
  135. <input type=""
  136. :value="newFee.name"
  137. @input="(e) => newFee.name = stripLetters(e)">
  138. <label>Amount</label>
  139. <input
  140. type=""
  141. :value="newFee.amount"
  142. @input="(e) => newFee.amount = strip(e)">
  143. <select id="" name="" v-model="newFee.type">
  144. <option value="Title Company">Title Company</option>
  145. <option value="Government">Government</option>
  146. <option value="Lender">Lender</option>
  147. <option value="Services Required by Lender">Required by Lender</option>
  148. <option value="Other">Other</option>
  149. </select>
  150. <button :disabled="!validFee" @click="addFee(true)">Debit</button>
  151. <button :disabled="!validFee" @click="addFee(false)">Credit</button>
  152. </Dialog>
  153. <section class="form radios">
  154. <h3>Mortgage Insurance</h3>
  155. <input type="radio" name="transaction_type" value="transaction"
  156. @input="e => estimate.transaction = 0"
  157. selected="estimate.transaction == 0">
  158. <label>1.43% - National MI</label>
  159. <input type="radio" name="transaction_type" value="refinance"
  160. @input="e => estimate.transaction = 1"
  161. selected="estimate.transaction == 1">
  162. <label>0.73% - MGIC</label>
  163. </section>
  164. <section class="form inputs">
  165. <button @click="generate">Generate</button>
  166. <div class="errors">
  167. <span v-for="e in errors">{{e}}</span>
  168. </div>
  169. </section>
  170. </div>
  171. </template>
  172. <script>
  173. import Dialog from "./dialog.vue"
  174. // The default values of a new estimate
  175. const example = {
  176. title: "Example",
  177. type: "",
  178. term: 0,
  179. ltv: 0, // Loan to home value ratio
  180. dti: 0,
  181. housingDti: 0,
  182. amount: 0,
  183. interest: 0,
  184. interestDays: 0,
  185. hazard: 0, // Hazard insurance monthly payment
  186. hazardEscrow: 0, // Hazard insurance escrow in months (0 is none)
  187. tax: 0, // Real Estate taxes monthly payment
  188. taxEscrow: 0, // Months to escrow (0 is none)
  189. hoa: 100, // Home owner's association monthly fee
  190. program: "",
  191. pud: true, // Property under development
  192. zip: '',
  193. fees: [],
  194. }
  195. // The default loans on a new estimate
  196. const loans = [
  197. Object.assign({}, example,),
  198. Object.assign(
  199. Object.assign({}, example),
  200. {title: "Another One",}
  201. ),
  202. ]
  203. // Default estimate fields
  204. const estimate = {
  205. property: "",
  206. transaction: 0,
  207. price: 0,
  208. borrowers: 0,
  209. creditScore: 0,
  210. mIncome: 0,
  211. loans: loans,
  212. }
  213. const newFee = {
  214. name: '', type: '', amount: 0
  215. }
  216. // Clone loan from initial example as a new loan
  217. function create() {
  218. this.estimate.loans.push(
  219. Object.assign({}, example, {fees: createFees()})
  220. )
  221. }
  222. function createFees() {
  223. return this.fees.map(f => Object.assign({}, f))
  224. }
  225. // Setup this.newFee for the creation dialog
  226. function createFee() {
  227. this.newFee = Object.assign({}, newFee)
  228. }
  229. function resetFees() {
  230. this.estimate.loans[this.sel].fees = this.createFees()
  231. }
  232. // If valid, add the current this.newFee to the array of fees and reset
  233. // this.newFee to null
  234. function addFee(isDebit) {
  235. const fee = this.newFee
  236. if (!this.validFee) {
  237. return
  238. }
  239. if (!isDebit) fee.amount = fee.amount * -1
  240. this.estimate.loans[this.sel].fees.push(fee)
  241. this.newFee = null
  242. }
  243. function validFee() {
  244. const fee = this.newFee
  245. if (!fee.name || !fee.type || !fee.amount) {
  246. return false
  247. }
  248. return true
  249. }
  250. // Strips non-digits from an input box event and returns it's rounded integer.
  251. // It also preserves current valid entry (.)
  252. function strip(e) {
  253. let valid = e.target.value.match(/\d+\.?\d?\d?/)?.[0] ?? ""
  254. e.target.value = valid
  255. return Number(valid || 0)
  256. }
  257. function stripInt(e) {
  258. let value = parseInt(e.target.value.replace(/\D/g, '') || 0)
  259. e.target.value = value
  260. return value
  261. }
  262. function stripPerc(e) {
  263. let num = strip(e)
  264. if (num > 100) {
  265. num = 100
  266. e.target.value = num
  267. }
  268. if (num < 0) {
  269. num = 0
  270. e.target.value = num
  271. }
  272. return num
  273. }
  274. function stripLetters(e) {
  275. let value = (e.target.value.replace(/[^\w\s]/g, '').slice(0, 20) || '')
  276. e.target.value = value
  277. return value
  278. }
  279. function del() {
  280. if (this.loans.length > 1) {
  281. let x = this.sel
  282. this.sel = 0
  283. this.loans.splice(x, 1)
  284. }
  285. }
  286. // Changes loan.ltv's <input> and data() values, then syncs with data.amount
  287. function setLtv(e) {
  288. let ltv = strip(e)
  289. let loan = this.loans[this.sel]
  290. if (!this.estimate.price) return
  291. if (ltv > 100) ltv = 100
  292. if (ltv < 0) ltv = 0
  293. loan.ltv = ltv
  294. loan.amount = (ltv / 100 * this.estimate.price).toFixed(2)
  295. }
  296. // Changes loan.amount's <input> and data() values, then syncs with data.ltv
  297. function setAmount(e) {
  298. let amount = strip(e)
  299. let loan = this.loans[this.sel]
  300. if (!this.estimate.price) return
  301. if (amount > loan.price) amount = loan.price
  302. if (amount < 0) amount = 0
  303. loan.amount = amount
  304. loan.ltv = (amount / this.estimate.price * 100).toFixed(2)
  305. }
  306. // Updates the property price for all loans
  307. function setPrice(e) {
  308. let value = strip(e)
  309. this.estimate.price = value
  310. }
  311. function setDti(e) {
  312. let dti = strip(e)
  313. let loan = this.loans[this.sel]
  314. if (!loan.price) return
  315. if (dti > 100) dti = 100
  316. if (dti < 0) dti = 0
  317. e.target.value = dti
  318. loan.dti = dti
  319. }
  320. function setHousingDti(e) {
  321. let housingDti = strip(e)
  322. let loan = this.loans[this.sel]
  323. if (!loan.price) return
  324. if (housingDti > 100) housingDti = 100
  325. if (housingDti < 0) housingDti = 0
  326. e.target.value = housingDti
  327. loan.housingDti = housingDti
  328. }
  329. function generate() {
  330. this.errors = this.validate()
  331. }
  332. function validate() {
  333. let errors = []
  334. const estimate = this.estimate
  335. // Alternative attribute names for error messages
  336. const names = {
  337. term: "loan term",
  338. ltv: "loan to value",
  339. hazard: "hazard insurance",
  340. hazardEscrow: "hazard insurance escrow",
  341. }
  342. if (!estimate.property) {
  343. errors.push("Missing property type.")
  344. } else if (!estimate.price) {
  345. errors.push("Missing property price.")
  346. } else if (!estimate.borrowers) {
  347. errors.push("Missing number of borrowers.")
  348. } else if (!estimate.creditScore) {
  349. errors.push("Missing credit score.")
  350. } else if (!estimate.mIncome) {
  351. errors.push("Missing monthly income.")
  352. }
  353. return errors
  354. }
  355. export default {
  356. components: { Dialog },
  357. methods: {
  358. setPrice, setLtv, setAmount, setDti, setHousingDti, strip, stripInt,
  359. stripLetters, stripPerc, del, create, createFees, createFee, resetFees,
  360. addFee, generate, validate
  361. },
  362. computed: {
  363. validFee,
  364. },
  365. props: ['user', 'fees'],
  366. data() {
  367. return {
  368. estimate: estimate,
  369. loans: estimate.loans,
  370. sel: 0,
  371. newFee: null,
  372. errors: [],
  373. }
  374. },
  375. created() {
  376. this.estimate.loans.forEach(l => l.fees = this.createFees())
  377. }
  378. }
  379. </script>