Website: Update license dispenser form (#23838)

Closes: https://github.com/fleetdm/confidential/issues/7696

Changes:
- Added `stripe` as a dependency
- Updated the license dispenser form to take users to a stripe hosted
checkout page where they can provide their billing address and Tax ID
depending on their location.
- Updated the receive-from-stripe webhook to fulfill license dispenser
purchases made via stripe checkout
- Added a new action: get-stripe-checkout-session-url. This action
creates a Stripe Checkout session and returns the URL
- Updated the customer dashboard to have a link that users can visit to
update their billing information, add more hosts to their Fleet premium
license, or cancel their subscription.
- Added a new action: redirect-to-stripe-billing-portal. An action that
redirects users to a Stripe-hosted billing portal.
This commit is contained in:
Eric
2024-11-25 13:55:56 -06:00
committed by GitHub
parent 7d04119245
commit c53332259f
10 changed files with 187 additions and 53 deletions
@@ -0,0 +1,70 @@
module.exports = {
friendlyName: 'Get Stripe checkout session url',
description: 'Creates a Stripe checkout session for a new Fleet Premium subscription and returns the URL',
inputs: {
quoteId: {
type: 'number',
required: true,
description: 'The quote to use (determines the price and number of hosts.)'
},
},
exits: {
success: {
description: 'A Stripe checkout session was successfully created for a new Fleet Premium subscription.'
}
},
fn: async function (inputs) {
// Configure Stripe
const stripe = require('stripe')(sails.config.custom.stripeSecret);
// Find the quote record that was created.
let quoteRecord = await Quote.findOne({id: inputs.quoteId});
if(!quoteRecord) {
throw new Error(`Consistency violation: The specified quote (${inputs.quoteId}) no longer seems to exist.`);
}
// What if the stripe customer id doesn't already exist on the user?
if (!this.req.me.stripeCustomerId) {
throw new Error(`Consistency violation: The logged-in user's (${this.req.me.emailAddress}) Stripe customer id has somehow gone missing!`);
}
// Create a new Stripe checkout session for this subscription.
let stripeCheckoutSession = await stripe.checkout.sessions.create({
customer: this.req.me.stripeCustomerId,
customer_update: {// eslint-disable-line camelcase
name: 'auto',
address: 'auto',
},
success_url: `${sails.config.custom.baseUrl}/customers/dashboard?order-complete`,// eslint-disable-line camelcase
line_items: [// eslint-disable-line camelcase
{
price: sails.config.custom.stripeSubscriptionPriceId,
quantity: quoteRecord.numberOfHosts,
},
],
mode: 'subscription',
billing_address_collection: 'required',// eslint-disable-line camelcase
tax_id_collection: {// eslint-disable-line camelcase
enabled: true,
required: 'if_supported'
}
});
// Return the url of the Stripe checkout session.
// Users will be taken to this URL via the handleSubmitting function of the <ajax-form> on the /customers/new-license page.
return stripeCheckoutSession.url;
}
};
@@ -0,0 +1,41 @@
module.exports = {
friendlyName: 'Redirect to stripe billing portal',
description: 'Creates a Stripe billing portal session for a Fleet Premium subscriber and redirects them.',
exits: {
redirect: {
responseType: 'redirect',
description: 'The requesting user is being redirected to the Stripe customer billing portal.'
},
noSubscription: {
responseType: 'redirect',
description: 'The Requesting user does not have a Fleet premium subscription.'
},
},
fn: async function () {
// Note: This action is covered by the 'is-logged-in' policy.
const stripe = require('stripe')(sails.config.custom.stripeSecret);
let thisUsersSubscription = await Subscription.findOne({user: this.req.me.id});
if(!thisUsersSubscription){
throw {noSubscription: '/customers/new-license'};
}
let session = await stripe.billingPortal.sessions.create({
customer: this.req.me.stripeCustomerId,
return_url: `${sails.config.custom.baseUrl}/customers/dashboard`,// eslint-disable-line camelcase
});
// All done.
throw {redirect: session.url};
}
};
+14 -3
View File
@@ -22,7 +22,7 @@ module.exports = {
fn: async function () {
const stripe = require('stripe')(sails.config.custom.stripeSecret);
const today = Date.now();
const oneYearInMs = (1000 * 60 * 60 * 24 * 365);
const oneYearAgoAt = today - oneYearInMs;
@@ -30,24 +30,33 @@ module.exports = {
const thirtyDaysFromNowAt = today + (1000 * 60 * 60 * 24 * 30);
let subscriptionHasBeenRecentlyRenewed = false;
let subscriptionExpiresSoon = false;
let subscriptionIsExpired = false;
// Get subscription Info
let thisSubscription = await Subscription.findOne({user: this.req.me.id});
// If the user does not have a subscription, then help them subscribe.
if(!thisSubscription) {
throw {redirect: '/customers/new-license'};
}
let stripeSubscriptionDetails = await stripe.subscriptions.retrieve(thisSubscription.stripeSubscriptionId);
let willSubscriptionRenew = true;
if(stripeSubscriptionDetails.cancel_at_period_end === true){
willSubscriptionRenew = false;
}
// If this subscription is over a year old, and was renewed in the past 30 days set subscriptionHasBeenRecentlyRenewed to true.
if(thisSubscription.createdAt <= oneYearAgoAt && (thisSubscription.nextBillingAt - oneYearInMs) >= thirtyDaysAgoAt) {
subscriptionHasBeenRecentlyRenewed = true;
}
// If this subscription will renew in the next 30 days, set subscriptionExpiresSoon to true.
if(thisSubscription.nextBillingAt <= thirtyDaysFromNowAt){
if(thisSubscription.nextBillingAt <= thirtyDaysFromNowAt && willSubscriptionRenew){
subscriptionExpiresSoon = true;
}
// If this subscription is expired, set subscriptionIsExpired to true.
if(thisSubscription.nextBillingAt <= Date.now()){
subscriptionIsExpired = true;
}
// Respond with view.
return {
@@ -55,6 +64,8 @@ module.exports = {
thisSubscription,
subscriptionExpiresSoon,
subscriptionHasBeenRecentlyRenewed,
willSubscriptionRenew,
subscriptionIsExpired,
};
}
+38 -5
View File
@@ -41,7 +41,7 @@ module.exports = {
fn: async function ({id, type, data, webhookSecret}) {
const stripe = require('stripe')(sails.config.custom.stripeSecret);
let assert = require('assert');
if(!this.req.get('stripe-signature')) {
@@ -77,6 +77,7 @@ module.exports = {
'invoice.payment_action_required',// Sent when a user's billing card requires additional verification from stripe.
'invoice.updated',// Sent before an incomplete invoice is voided. (~24 hours after a payment fails)
'invoice.voided',// Sent when an incomplete invoice is marked as voided. (~24 hours after a payment fails)
'checkout.session.completed'// Sent when a user completes a Stripe Checkout session.
];
// If this event is for a subscription that was just created, we won't have a matching Subscription record in the database. This is because we wait until the subscription's invoice is paid to create the record in our database.
@@ -86,15 +87,16 @@ module.exports = {
throw new Error(`The Stripe subscription events webhook received a event for a subscription with stripeSubscriptionId: ${subscriptionIdToFind}, but no matching record was found in our database.`);
} else {
let userReferencedInStripeEvent = await User.findOne({stripeCustomerId: stripeEventData.customer});
if(!userReferencedInStripeEvent){
if(!userReferencedInStripeEvent) {
throw new Error(`The receive-from-stripe webhook received an event for an invoice (type: ${type}) for a subscription (stripeSubscriptionId: ${subscriptionIdToFind}) but no matching Subscription or User record (stripeCustomerId: ${stripeEventData.customer}) was found in our databse.`);
} else {
return;
}
}
}
let userForThisSubscription = subscriptionForThisEvent.user;
let userForThisSubscription = await User.findOne({stripeCustomerId: stripeEventData.customer});
if(!userForThisSubscription){
throw new Error(`The stripe subscription events webhook received a tpye ${type} event for a user with stripeCustomerId: ${stripeEventData.customer}, but no matching user was found in the databse. Stripe event ID: ${id}`);
}
// ┬ ┬┌─┐┌─┐┌─┐┌┬┐┬┌┐┌┌─┐ ┬─┐┌─┐┌┐┌┌─┐┬ ┬┌─┐┬
// │ │├─┘│ │ │││││││││ ┬ ├┬┘├┤ │││├┤ │││├─┤│
// └─┘┴ └─┘└─┘┴ ┴┴┘└┘└─┘ ┴└─└─┘┘└┘└─┘└┴┘┴ ┴┴─┘
@@ -200,6 +202,37 @@ module.exports = {
fleetLicenseKey: newLicenseKeyForThisSubscription,
nextBillingAt: nextBillingAt
});
} else if(type === 'checkout.session.completed' && stripeEventData.payment_status === 'paid') {
// For handling successful payments from a Stripe checkout session.
// Note: This event is sent the moment the user's payment succeeds.
if(subscriptionForThisEvent){// Throw an error if there is an existing subscription with this ID that matches this event in the website's database.
throw new Error(`Consistency violation! The stripe webhook received a "${type}" event for a new subscription being created, but a subscription with the stripe ID ${subscriptionForThisEvent.stripeSubscriptionId} already exists.`);
}
// Retrieve the subscription details from Stripe.
let newSubscriptionDetails = await stripe.subscriptions.retrieve(stripeEventData.subscription);
// Convert the timestamp of the next time this subscription will be billed into a JS timestamp (Epoch MS)
let nextBillingAt = newSubscriptionDetails.current_period_end * 1000;
// Get the number of Hosts.
let numberOfHosts = newSubscriptionDetails.quantity;
// Get the whole dollar price per host.
let subscriptionPricePerHost = newSubscriptionDetails.plan.amount / 100;
// Determine the annual cost of this user's subscription
let subscriptionPrice = subscriptionPricePerHost * numberOfHosts;
// Generate a new license key.
let newLicenseKey = await sails.helpers.createLicenseKey.with({
numberOfHosts,
organization: userForThisSubscription.organization,
expiresAt: nextBillingAt,
});
// Create the database record for this subscription.
await Subscription.create({
nextBillingAt,
numberOfHosts,
subscriptionPrice,
stripeSubscriptionId: newSubscriptionDetails.id,
fleetLicenseKey: newLicenseKey,
user: userForThisSubscription.id,
});
}
// FUTURE: send emails about failed payments. (type === 'invoice.payment_failed' && stripeEventData.billing_reason === 'subscription_cycle')
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -19,6 +19,10 @@ parasails.registerPage('new-license', {
selfHostedAcknowledgment: {required: true, is: true},
},
checkoutFormRules: {
selfHostedAcknowledgment: {required: true, is: true},
},
// Syncing / loading state
syncing: false,
@@ -93,7 +97,12 @@ parasails.registerPage('new-license', {
this.syncing = true;
this.goto('/customers/dashboard?order-complete');
},
handleSubmittingCheckoutForm: async function() {
let redirectUrl = await Cloud.getStripeCheckoutSessionUrl.with({
quoteId: this.formData.quoteId
});
this.goto(redirectUrl);
},
submittedQuoteForm: async function(quote) {
this.showQuotedPrice = true;
this.quotedPrice = quote.quotedPrice;
+2
View File
@@ -136,6 +136,7 @@ module.exports.routes = {
pageDescriptionForMeta: 'View and edit information about your Fleet Premium license.',
}
},
'GET /customers/update-subscription': { action: 'customers/redirect-to-stripe-billing-portal' },
'GET /customers/forgot-password': {
action: 'entrance/view-forgot-password',
locals: {
@@ -675,4 +676,5 @@ module.exports.routes = {
'POST /api/v1/account/update-start-cta-visibility': { action: 'account/update-start-cta-visibility' },
'POST /api/v1/deliver-deal-registration-submission': { action: 'deliver-deal-registration-submission' },
'/api/v1/unsubscribe-from-marketing-emails': { action: 'unsubscribe-from-marketing-emails' },
'POST /api/v1/customers/get-stripe-checkout-session-url': { action: 'customers/get-stripe-checkout-session-url' },
};
+2 -1
View File
@@ -17,7 +17,8 @@
"sails-hook-organics": "^3.0.0",
"sails-hook-orm": "^4.0.3",
"sails-hook-sockets": "^3.0.0",
"sails-postgresql": "^5.0.1"
"sails-postgresql": "^5.0.1",
"stripe": "17.3.1"
},
"devDependencies": {
"eslint": "5.16.0",
+6 -11
View File
@@ -52,7 +52,7 @@
<div class="row pb-4">
<div class="col-12 col-lg-6 pb-4 pb-lg-0">
<h3 class="pt-2 pt-sm-3">Your details</h3>
<div purpose="details-card" class="card card-body justify-content-top">
<div purpose="details-card" class="card card-body justify-content-start">
<div class="row">
<div class="col-sm-4 col-12 pb-2 pb-sm-0">Organization:</div>
<div class="col-sm-8 col-12 text-left text-sm-right">
@@ -85,19 +85,14 @@
<div class="col-12 col-lg-6">
<h3 class="pt-2 pt-sm-3">Billing and payment</h3>
<div purpose="billing-card" class="card card-body justify-content-center" v-if="me.hasBillingCard">
<div class="row pb-3 mx-0">
<div style="max-width: 16px;" class="col-1 px-0"><img style="margin-top: 5px; height: 12px; width: 16px;" src="/images/icon-card-32x24@2x.png" alt="A credit card Icon"></div>
<div class="col pl-3">
<p>{{me.billingCardBrand}} ending in <strong>{{me.billingCardLast4}}</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickUpdateBillingCardButton()"></p>
</div>
</div>
<div purpose="billing-card" class="card card-body justify-content-start" v-if="!subscriptionIsExpired">
<div class="row pb-3 mx-0">
<div style="max-width: 16px;" class="col-1 px-0"><img style="margin-top: 5px; height: 16px; width: 16px;" src="/images/icon-calendar-32x32@2x.png" alt="A calendar icon"></div>
<div class="col pl-3">
<p>{{thisSubscription.numberOfHosts}} devices @ ${{thisSubscription.subscriptionPrice / thisSubscription.numberOfHosts / 12}}.00/device/month</p>
<p>Billed annually at ${{thisSubscription.subscriptionPrice}}.00/yr</p>
<p>Next payment on <js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
<p v-if="willSubscriptionRenew">Next payment on <js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
<p v-else>Your subscription will expire on <js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
</div>
</div>
<div purpose="contact">
@@ -106,7 +101,7 @@
<img style="display: inline-block; height: 16px; width: 16px; margin-top: -3px;" src="/images/info-16x16@2x.png" alt="An icon indicating that this section has important information">
</div>
<div class="col ml-1 pl-1 small">
<p class="small"><a href="/contact" target="_blank">Contact us</a> to change your number of devices, or to cancel your subscription.</p>
<p class="small"><a href="/customers/update-subscription">Click here</a> to change your number of devices, or to cancel your subscription.</p>
</div>
</div>
</div>
@@ -117,7 +112,7 @@
<img style="display: inline-block; height: 16px; width: 16px; " src="/images/icon-info-grey-16x16@2x.png" alt="An icon indicating that this section has important information">
</div>
<div class="col ml-1 pl-1 small">
<p class="small">Your subscription will expire on <js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
<p class="small">Your subscription ended on <js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
</div>
</div>
</div>
+3 -31
View File
@@ -43,35 +43,7 @@
</div>
<div class="card card-body mt-3" v-if="showBillingForm">
<h3 class="pb-3">Billing information</h3>
<ajax-form action="saveBillingInfoAndSubscribe" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="billingFormRules" @submitted="submittedPaymentForm()" v-if="!cloudError || cloudError === 'couldNotSaveBillingInfo' || cloudError === 'cardVerificationRequired'">
<div class="form-group">
<label for="card">Billing Card</label>
<stripe-card-element class="mb-3" id="card" busy.sync="syncing" :is-errored.sync="formErrors.paymentSource" :stripe-publishable-key="stripePublishableKey"
v-model="formData.paymentSource" key="billing-card" ref="paymentcardref"></stripe-card-element>
</div>
<div v-if="showAdditionalBillingFormInputs">
<div class="form-group">
<label for="organization">Organization</label>
<input class="form-control" id="organization" type="text" :class="[formErrors.organization ? 'is-invalid' : formErrors.organization === '' ]" v-model.trim="formData.organization">
<div class="invalid-feedback" v-if="formErrors.organization">Please enter the name of your organization.</div>
</div>
<div class="row">
<div class="col-12 col-sm-6 pr-sm-2">
<div class="form-group">
<label for="first-name">First name</label>
<input class="form-control" id="first-name" type="text" :class="[formErrors.firstName ? 'is-invalid' : '']" v-model.trim="formData.firstName" autocomplete="first-name">
<div class="invalid-feedback" v-if="formErrors.firstName">Please enter your first name.</div>
</div>
</div>
<div class="col-12 col-sm-6 pl-sm-2">
<div class="form-group">
<label for="last-name">Last name</label>
<input class="form-control" id="last-name" type="text" :class="[formErrors.lastName ? 'is-invalid' : '']" v-model.trim="formData.lastName" autocomplete="last-name">
<div class="invalid-feedback" v-if="formErrors.lastName">Please enter your last name.</div>
</div>
</div>
</div>
</div>
<ajax-form :handle-submitting="handleSubmittingCheckoutForm" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="checkoutFormRules" v-if="!cloudError || cloudError === 'couldNotSaveBillingInfo' || cloudError === 'cardVerificationRequired'">
<div class="form-group" purpose="self-hosted-checkbox">
<input type="checkbox" id="self-hosted-acknowledgment" v-model.trim="formData.selfHostedAcknowledgment" @input="clickClearOneFormError('selfHostedAcknowledgment')">
<label purpose="self-hosted-note" :class="[formErrors.selfHostedAcknowledgment ? 'is-invalid' : '']" for="self-hosted-acknowledgment">I understand that managed cloud hosting is not available for less than 300 hosts. I will host Fleet myself.</label>
@@ -84,12 +56,12 @@
<cloud-error purpose="cloud-error" v-else-if="cloudError === 'cardVerificationRequired'">
<p>The billing card provided could not be used without additional verification. Please use another card or <a href="/contact" target="_blank">contact support</a> to complete your order.</p>
</cloud-error>
<ajax-button purpose="submit-button" spinner="true" :syncing="syncing" class="btn btn-block btn-lg btn-primary mt-4">Get license key</ajax-button>
<ajax-button purpose="submit-button" spinner="true" :syncing="syncing" class="btn btn-block btn-lg btn-primary mt-4">Checkout</ajax-button>
</ajax-form>
<cloud-error purpose="cloud-error" v-else-if="cloudError">
<p class="mb-3 text-bold text-strong">An error has occurred while processing your request.</p>
<p class="mb-2">We're sorry that this happened. A human has been informed of this error and is looking into it.</p>
<p>Feel free to <a href="/customers/new-license">reload the page</a> and try again. A team member at Fleet will investigate and correct duplicate charges, if any occurred.</p>
<p>Feel free to <a href="/customers/new-license">reload the page</a> and try again.</p>
</cloud-error>
</div>
</div>