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"> | |||
<template v-if="user"> | |||
<side-bar v-if="user" | |||
:role="user && user.status" | |||
:avatar="user.avatar" | |||
@@ -13,6 +14,7 @@ | |||
<spinner></spinner> | |||
</div> | |||
<home :user="user" v-else-if="active == 1" /> | |||
<new-estimate | |||
:user="user" | |||
@@ -37,6 +39,8 @@ v-else-if="active == 3" | |||
@updateLetterhead="updateLetterhead" | |||
v-else-if="active == 4" /> | |||
<biller v-if="invalidSub" :user="user"/> | |||
<sign-out :user="user" v-else-if="active == 5" /> | |||
</template> | |||
@@ -57,6 +61,7 @@ import Estimates from "./estimates.vue" | |||
import Settings from "./settings.vue" | |||
import SignOut from "./sign-out.vue" | |||
import Login from "./login.vue" | |||
import Biller from "./update-billing/update-billing.vue" | |||
function getCookie(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() { | |||
const token = getCookie("skouter") | |||
this.token = token | |||
@@ -206,17 +225,28 @@ function active() { | |||
// Fetch data before showing UI. If requests fail, assume token is expired. | |||
function start() { | |||
this.loading = true | |||
const validStatuses = ["incomplete", "trialing", "active"] | |||
let loaders = [] | |||
loaders.push(this.getUser()) | |||
loaders.push(this.getFees()) | |||
Promise.all(loaders).then((a, b) => { | |||
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 | |||
// reloaded | |||
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 => { | |||
console.log("An error occured %O", error) | |||
this.loadingError = "Could not initialize app." | |||
@@ -249,6 +279,7 @@ export default { | |||
NewEstimate, | |||
Estimates, | |||
Settings, | |||
Biller, | |||
SignOut, | |||
Login | |||
}, | |||
@@ -273,6 +304,7 @@ export default { | |||
fees: [], | |||
loadingError: "", | |||
token: '', | |||
invalidSub: false, | |||
} | |||
}, | |||
created() { | |||
@@ -1,5 +1,5 @@ | |||
<template> | |||
<div class="modal"> | |||
<div class="modal-prompt"> | |||
<section class="form inputs dialog"> | |||
<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} ), | |||
}).then(response => { | |||
if (response.ok) { | |||
return response.text() | |||
this.$emit('login') | |||
window.location.hash = '' | |||
} else { | |||
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; | |||
} | |||
div.modal { | |||
div.modal-prompt { | |||
position: fixed; | |||
z-index: 5; | |||
width: 100vw; | |||
width: 100vw; | |||
height: 100vh; | |||
top: 0; | |||
left: 0; | |||
display: block; | |||
opacity: 1; | |||
padding: 10px; | |||
} | |||
div.modal .form { | |||
div.modal-prompt .form { | |||
z-index: 5; | |||
margin: auto auto; | |||
margin-top: 10%; | |||
width: 100%; | |||
max-width: 300px; | |||
max-width: 90%; | |||
padding: 20px 10px; | |||
background: white; | |||
box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; | |||
border-radius: 3px; | |||
} | |||
div.modal .form img.close-btn { | |||
div.modal-prompt .form img.close-btn { | |||
right: 10px; | |||
top: 10px; | |||
position: absolute; | |||
@@ -1191,8 +1191,12 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||
u.verified, | |||
u.role, | |||
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) | |||
@@ -1214,6 +1218,7 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||
&user.Role, | |||
&user.Address.Id, | |||
&user.Phone, | |||
&user.Sub.Id, | |||
) | |||
if err != nil { | |||
@@ -1224,6 +1229,11 @@ func queryUser(db *sql.DB, id int) (User, error) { | |||
if err != nil { | |||
return user, err | |||
} | |||
user.Sub, err = querySub(db, user.Sub.Id) | |||
if err != nil { | |||
return user, err | |||
} | |||
if user.Branch.Id > 0 { | |||
user.Branch, err = queryBranch(db, user.Branch.Id) | |||
@@ -1387,6 +1397,7 @@ func querySub(db *sql.DB, id int) (Subscription, error) { | |||
err = row.Scan( | |||
&s.Id, | |||
&s.StripeId, | |||
&s.UserId, | |||
&s.CustomerId, | |||
&s.End, | |||
&s.Start, | |||