automatically prompts for a new card if past payments were unsuccessful. Does not actually disable API access.master
@@ -2,6 +2,7 @@ | |||||
<div class="app-panel"> | <div class="app-panel"> | ||||
<template v-if="user"> | <template v-if="user"> | ||||
<side-bar v-if="user" | <side-bar v-if="user" | ||||
:role="user && user.status" | :role="user && user.status" | ||||
:avatar="user.avatar" | :avatar="user.avatar" | ||||
@@ -13,6 +14,7 @@ | |||||
<spinner></spinner> | <spinner></spinner> | ||||
</div> | </div> | ||||
<home :user="user" v-else-if="active == 1" /> | <home :user="user" v-else-if="active == 1" /> | ||||
<new-estimate | <new-estimate | ||||
:user="user" | :user="user" | ||||
@@ -37,6 +39,8 @@ v-else-if="active == 3" | |||||
@updateLetterhead="updateLetterhead" | @updateLetterhead="updateLetterhead" | ||||
v-else-if="active == 4" /> | v-else-if="active == 4" /> | ||||
<biller v-if="invalidSub" :user="user"/> | |||||
<sign-out :user="user" v-else-if="active == 5" /> | <sign-out :user="user" v-else-if="active == 5" /> | ||||
</template> | </template> | ||||
@@ -57,6 +61,7 @@ import Estimates from "./estimates.vue" | |||||
import Settings from "./settings.vue" | import Settings from "./settings.vue" | ||||
import SignOut from "./sign-out.vue" | import SignOut from "./sign-out.vue" | ||||
import Login from "./login.vue" | import Login from "./login.vue" | ||||
import Biller from "./update-billing/update-billing.vue" | |||||
function getCookie(name) { | function getCookie(name) { | ||||
var re = new RegExp(name + "=([^;]+)") | var re = new RegExp(name + "=([^;]+)") | ||||
@@ -86,6 +91,20 @@ function refreshToken() { | |||||
}) | }) | ||||
} | } | ||||
function fetchUser() { | |||||
return fetch(`/api/user`, | |||||
{method: 'GET', | |||||
headers: { | |||||
"Accept": "application/json", | |||||
"Authorization": `Bearer ${getCookie("skouter")}`, | |||||
}, | |||||
}).then(response => { | |||||
if (response.ok) { | |||||
return response.json() | |||||
} | |||||
}) | |||||
} | |||||
function getUser() { | function getUser() { | ||||
const token = getCookie("skouter") | const token = getCookie("skouter") | ||||
this.token = token | this.token = token | ||||
@@ -206,17 +225,28 @@ function active() { | |||||
// Fetch data before showing UI. If requests fail, assume token is expired. | // Fetch data before showing UI. If requests fail, assume token is expired. | ||||
function start() { | function start() { | ||||
this.loading = true | this.loading = true | ||||
const validStatuses = ["incomplete", "trialing", "active"] | |||||
let loaders = [] | let loaders = [] | ||||
loaders.push(this.getUser()) | loaders.push(this.getUser()) | ||||
loaders.push(this.getFees()) | loaders.push(this.getFees()) | ||||
Promise.all(loaders).then((a, b) => { | Promise.all(loaders).then((a, b) => { | ||||
this.loading = false | this.loading = false | ||||
if (!b) { | |||||
if (!b) { // !b means there is no rejection error for any promise | |||||
// Time untill token expiration may have elapsed before the page | // Time untill token expiration may have elapsed before the page | ||||
// reloaded | // reloaded | ||||
this.refreshToken() | this.refreshToken() | ||||
fetchUser().then( u => { | |||||
if (u.sub.customerId && | |||||
validStatuses.includes(u.sub.paymentStatus)) { | |||||
return | |||||
} | |||||
// Payment must be updated | |||||
console.log("paying...") | |||||
this.invalidSub = true | |||||
}) | |||||
} | } | ||||
window.location.hash = '' | |||||
}).catch(error => { | }).catch(error => { | ||||
console.log("An error occured %O", error) | console.log("An error occured %O", error) | ||||
this.loadingError = "Could not initialize app." | this.loadingError = "Could not initialize app." | ||||
@@ -249,6 +279,7 @@ export default { | |||||
NewEstimate, | NewEstimate, | ||||
Estimates, | Estimates, | ||||
Settings, | Settings, | ||||
Biller, | |||||
SignOut, | SignOut, | ||||
Login | Login | ||||
}, | }, | ||||
@@ -273,6 +304,7 @@ export default { | |||||
fees: [], | fees: [], | ||||
loadingError: "", | loadingError: "", | ||||
token: '', | token: '', | ||||
invalidSub: false, | |||||
} | } | ||||
}, | }, | ||||
created() { | created() { | ||||
@@ -1,5 +1,5 @@ | |||||
<template> | <template> | ||||
<div class="modal"> | |||||
<div class="modal-prompt"> | |||||
<section class="form inputs dialog"> | <section class="form inputs dialog"> | ||||
<img width="21" height="21" src="/assets/image/icon/x-red.svg" | <img width="21" height="21" src="/assets/image/icon/x-red.svg" | ||||
@@ -23,14 +23,11 @@ function login() { | |||||
body: JSON.stringify( {email: this.email, password: this.password} ), | body: JSON.stringify( {email: this.email, password: this.password} ), | ||||
}).then(response => { | }).then(response => { | ||||
if (response.ok) { | if (response.ok) { | ||||
return response.text() | |||||
this.$emit('login') | |||||
window.location.hash = '' | |||||
} else { | } else { | ||||
this.error = "Invalid credentials" | this.error = "Invalid credentials" | ||||
} | } | ||||
}).then(result => { | |||||
if (!result || !result.length) return // Exit if there is no token | |||||
this.$emit('login') | |||||
window.location.hash = '' | |||||
}) | }) | ||||
} | } | ||||
@@ -0,0 +1,42 @@ | |||||
<template> | |||||
<div> | |||||
<h4>Billing</h4> | |||||
<div id="payment-element"></div> | |||||
<div id="message"></div> | |||||
<button @click="submit" class="btn btn-primary">Submit</button> | |||||
</div> | |||||
</template> | |||||
<script setup> | |||||
import { ref, onMounted } from "vue" | |||||
const props = defineProps(["sub"]) | |||||
const stripe = Stripe(process.env.STRIPE_KEY) | |||||
const options = { clientSecret: props.sub.clientSecret } | |||||
const elements = stripe.elements(options) | |||||
const payEl = elements.create("payment") | |||||
function submit() { | |||||
let result = stripe.confirmPayment({ | |||||
//`Elements` instance that was used to create the Payment Element | |||||
elements, | |||||
confirmParams: { | |||||
return_url: "https://skouter.net/register", | |||||
} | |||||
}) | |||||
} | |||||
onMounted(() => { | |||||
payEl.mount("#payment-element") | |||||
}) | |||||
</script> | |||||
<style scoped> | |||||
button.btn { | |||||
margin: 10px auto; | |||||
display: block; | |||||
min-width: 90px; | |||||
} | |||||
</style> |
@@ -0,0 +1,19 @@ | |||||
<template> | |||||
<section> | |||||
<h4 v-if="status == 'succeeded'">Payment succeeded!</h4> | |||||
<h4 v-if="status == 'processing'">Payment still processing. Try again later.</h4> | |||||
</section> | |||||
</template> | |||||
<script setup> | |||||
import { ref, onMounted } from "vue" | |||||
const props = defineProps(["status"]) | |||||
</script> | |||||
<style> | |||||
h4 { | |||||
text-align: center; | |||||
} | |||||
</style> |
@@ -0,0 +1,35 @@ | |||||
<template> | |||||
<section> | |||||
<p> | |||||
Your payment information is missing or no longer valid, please update your billing details. | |||||
</p> | |||||
<button @click="() => $emit('ok')">OK</button> | |||||
</section> | |||||
</template> | |||||
<script setup> | |||||
import { ref } from "vue" | |||||
const emits = defineEmits(['ok']) | |||||
</script> | |||||
<style scoped> | |||||
form > div { | |||||
display: flex; | |||||
justify-content: space-between; | |||||
margin: 10px; | |||||
position: relative; | |||||
} | |||||
button { | |||||
margin: auto; | |||||
min-width: 90px; | |||||
display: block; | |||||
} | |||||
span.error { | |||||
margin: 10px auto; | |||||
color: darkred; | |||||
} | |||||
</style> |
@@ -0,0 +1,86 @@ | |||||
<template> | |||||
<div class="modal-prompt"> | |||||
<section class="shadowbox"> | |||||
<h2>Update Billing</h2> | |||||
<prompt v-if="step == 1" :err="err" @ok="() => step++" /> | |||||
<billing v-if="step == 2" :err="err" :sub="user.sub"/> | |||||
<completed v-if="step == 3" :err="err" :status="sub.paymentStatus"/> | |||||
</section> | |||||
</div> | |||||
</template> | |||||
<script setup> | |||||
import { ref, onMounted } from "vue" | |||||
import Dialog from "../dialog.vue" | |||||
import Prompt from "./prompt.vue" | |||||
import Billing from "./billing.vue" | |||||
import Completed from "./completed.vue" | |||||
let err = ref("") | |||||
const props = defineProps(['user']) | |||||
const stripe = Stripe(process.env.STRIPE_KEY) | |||||
const step = ref(1) | |||||
const token = ref("") | |||||
const sub = ref(null) | |||||
const clientSecret = new URLSearchParams(window.location.search).get( | |||||
'payment_intent_client_secret' | |||||
); | |||||
function getCookie(name) { | |||||
var re = new RegExp(name + "=([^;]+)") | |||||
var value = re.exec(document.cookie) | |||||
return (value != null) ? unescape(value[1]) : null | |||||
} | |||||
function intent(u) { | |||||
return | |||||
return fetch(`/api/user/subscribe`, | |||||
{method: 'POST', | |||||
body: JSON.stringify(u), | |||||
headers: { | |||||
"Accept": "application/json", | |||||
"Authorization": `Bearer ${token.value}`, | |||||
}, | |||||
}).then(resp => { | |||||
if (resp.ok) { | |||||
resp.json().then(s => { | |||||
err.value = "" | |||||
console.log(s) | |||||
sub.value = s | |||||
if (["processing", "succeeded"].includes(s.paymentStatus) && | |||||
clientSecret == s.clientSecret) { | |||||
step.value = step.value + 2 | |||||
} else if (s.paymentStatus == "requires_payment_method") { | |||||
step.value++ | |||||
} else { | |||||
step.value = 0 | |||||
} | |||||
}) | |||||
} else { | |||||
resp.text().then( e => err.value = e) | |||||
} | |||||
}) | |||||
} | |||||
onMounted(() => { | |||||
console.log(props.user) | |||||
}) | |||||
</script> | |||||
<style scoped> | |||||
section { | |||||
max-width: 400px; | |||||
margin: auto; | |||||
} | |||||
div.modal-prompt .form { | |||||
} | |||||
</style> |
@@ -342,29 +342,31 @@ section.form .fee img { | |||||
margin-left: auto; | margin-left: auto; | ||||
} | } | ||||
div.modal { | |||||
div.modal-prompt { | |||||
position: fixed; | position: fixed; | ||||
z-index: 5; | z-index: 5; | ||||
width: 100vw; | width: 100vw; | ||||
width: 100vw; | |||||
height: 100vh; | height: 100vh; | ||||
top: 0; | top: 0; | ||||
left: 0; | left: 0; | ||||
display: block; | |||||
opacity: 1; | |||||
padding: 10px; | |||||
} | } | ||||
div.modal .form { | |||||
div.modal-prompt .form { | |||||
z-index: 5; | z-index: 5; | ||||
margin: auto auto; | margin: auto auto; | ||||
margin-top: 10%; | margin-top: 10%; | ||||
width: 100%; | width: 100%; | ||||
max-width: 300px; | |||||
max-width: 90%; | |||||
padding: 20px 10px; | padding: 20px 10px; | ||||
background: white; | background: white; | ||||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; | box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; | ||||
border-radius: 3px; | border-radius: 3px; | ||||
} | } | ||||
div.modal .form img.close-btn { | |||||
div.modal-prompt .form img.close-btn { | |||||
right: 10px; | right: 10px; | ||||
top: 10px; | top: 10px; | ||||
position: absolute; | position: absolute; | ||||
@@ -1191,8 +1191,12 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||||
u.verified, | u.verified, | ||||
u.role, | u.role, | ||||
u.address, | u.address, | ||||
u.phone | |||||
FROM user u WHERE u.id = ? | |||||
u.phone, | |||||
s.id | |||||
FROM user u | |||||
LEFT JOIN subscription s | |||||
ON s.user_id = u.id | |||||
WHERE u.id = ? | |||||
` | ` | ||||
row := db.QueryRow(query, id) | row := db.QueryRow(query, id) | ||||
@@ -1214,6 +1218,7 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||||
&user.Role, | &user.Role, | ||||
&user.Address.Id, | &user.Address.Id, | ||||
&user.Phone, | &user.Phone, | ||||
&user.Sub.Id, | |||||
) | ) | ||||
if err != nil { | if err != nil { | ||||
@@ -1224,6 +1229,11 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||||
if err != nil { | if err != nil { | ||||
return user, err | return user, err | ||||
} | } | ||||
user.Sub, err = querySub(db, user.Sub.Id) | |||||
if err != nil { | |||||
return user, err | |||||
} | |||||
if user.Branch.Id > 0 { | if user.Branch.Id > 0 { | ||||
user.Branch, err = queryBranch(db, user.Branch.Id) | user.Branch, err = queryBranch(db, user.Branch.Id) | ||||
@@ -1387,6 +1397,7 @@ func querySub(db *sql.DB, id int) (Subscription, error) { | |||||
err = row.Scan( | err = row.Scan( | ||||
&s.Id, | &s.Id, | ||||
&s.StripeId, | &s.StripeId, | ||||
&s.UserId, | |||||
&s.CustomerId, | &s.CustomerId, | ||||
&s.End, | &s.End, | ||||
&s.Start, | &s.Start, | ||||