feat: Allow SaaS users to manage subscription within the dashboard (#5059)

This commit is contained in:
Pranav Raj S 2022-07-19 19:04:17 +05:30 committed by GitHub
parent 0cee42a9f9
commit 7fc0d166e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 773 additions and 18 deletions

View file

@ -72,6 +72,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
end

View file

@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
Current.user = @user
end
def current_subscription
@subscription ||= Current.account.subscription
end
def pundit_user
{
user: Current.user,

View file

@ -13,8 +13,7 @@ class DashboardController < ActionController::Base
def set_global_config
@global_config = GlobalConfig.get(
'LOGO',
'LOGO_THUMBNAIL',
'LOGO', 'LOGO_THUMBNAIL',
'INSTALLATION_NAME',
'WIDGET_BRAND_URL',
'TERMS_URL',
@ -29,7 +28,8 @@ class DashboardController < ActionController::Base
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE'
'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV'
).merge(app_config)
end

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class EnterpriseAccountAPI extends ApiClient {
constructor() {
super('', { accountScoped: true, enterprise: true });
}
checkout() {
return axios.post(`${this.url}checkout`);
}
subscription() {
return axios.post(`${this.url}subscription`);
}
}
export default new EnterpriseAccountAPI();

View file

@ -0,0 +1,31 @@
import accountAPI from '../account';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../../specs/apiSpecHelper';
describe('#enterpriseAccountAPI', () => {
it('creates correct instance', () => {
expect(accountAPI).toBeInstanceOf(ApiClient);
expect(accountAPI).toHaveProperty('get');
expect(accountAPI).toHaveProperty('show');
expect(accountAPI).toHaveProperty('create');
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('checkout');
});
describeWithAPIMock('API calls', context => {
it('#checkout', () => {
accountAPI.checkout();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/checkout'
);
});
it('#subscription', () => {
accountAPI.subscription();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/enterprise/api/v1/subscription'
);
});
});
});

View file

@ -19,6 +19,7 @@
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
@ -67,6 +68,7 @@ export default {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',

View file

@ -30,6 +30,7 @@ const settings = accountId => ({
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
@ -100,6 +101,14 @@ const settings = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
{
icon: 'credit-card-person',
label: 'BILLING',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',

View file

@ -55,6 +55,10 @@ export default {
type: String,
default: '',
},
isOnChatwootCloud: {
type: Boolean,
default: false,
},
},
computed: {
hasSecondaryMenu() {
@ -67,12 +71,18 @@ export default {
if (!this.currentRole) {
return [];
}
return this.menuConfig.menuItems.filter(
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
menuItem =>
window.roleWiseRoutes[this.currentRole].indexOf(
menuItem.toStateName
) > -1
);
return menuItemsFilteredByRole.filter(item => {
if (item.showOnlyOnCloud) {
return this.isOnChatwootCloud;
}
return true;
});
},
inboxSection() {
return {

View file

@ -167,6 +167,7 @@
"CUSTOM_ATTRIBUTES": "Custom Attributes",
"AUTOMATION": "Automation",
"TEAMS": "Teams",
"BILLING": "Billing",
"CUSTOM_VIEWS_FOLDER": "Folders",
"CUSTOM_VIEWS_SEGMENTS": "Segments",
"ALL_CONTACTS": "All Contacts",
@ -195,6 +196,25 @@
"CATEGORY": "Category"
}
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
"CURRENT_PLAN" : {
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **%{plan}** plan with **%{quantity}** licenses"
},
"MANAGE_SUBSCRIPTION": {
"TITLE": "Manage your subscription",
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View file

@ -0,0 +1,116 @@
<template>
<div class="columns profile--settings">
<woot-loading-state v-if="uiFlags.isFetchingItem" />
<div v-else-if="!hasABillingPlan">
<p>{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}</p>
</div>
<div v-else class="small-12 columns ">
<div class="current-plan--details">
<h6>{{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }}</h6>
<div
v-dompurify-html="
formatMessage(
$t('BILLING_SETTINGS.CURRENT_PLAN.PLAN_NOTE', {
plan: planName,
quantity: subscribedQuantity,
})
)
"
/>
</div>
<billing-item
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
:button-label="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT')"
@click="onClickBillingPortal"
/>
<billing-item
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
:button-label="$t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT')"
button-icon="chat-multiple"
@click="onToggleChatWindow"
/>
</div>
</div>
</template>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import accountMixin from '../../../../mixins/account';
import BillingItem from './components/BillingItem.vue';
export default {
components: { BillingItem },
mixins: [accountMixin, alertMixin, messageFormatterMixin],
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
}),
currentAccount() {
return this.getAccount(this.accountId) || {};
},
customAttributes() {
return this.currentAccount.custom_attributes || {};
},
hasABillingPlan() {
return !!this.planName;
},
planName() {
return this.customAttributes.plan_name || '';
},
subscribedQuantity() {
return this.customAttributes.subscribed_quantity || 0;
},
},
mounted() {
this.fetchAccountDetails();
},
methods: {
async fetchAccountDetails() {
await this.$store.dispatch('accounts/get');
if (!this.hasABillingPlan) {
this.$store.dispatch('accounts/subscription');
}
},
onClickBillingPortal() {
this.$store.dispatch('accounts/checkout');
},
onToggleChatWindow() {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
},
},
};
</script>
<style lang="scss">
.manage-subscription {
align-items: center;
background: var(--white);
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
margin-bottom: var(--space-small);
padding: var(--space-medium) var(--space-normal);
}
.current-plan--details {
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-normal);
padding-bottom: var(--space-normal);
}
.manage-subscription {
.manage-subscription--description {
margin-bottom: 0;
}
}
</style>

View file

@ -0,0 +1,26 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/billing'),
roles: ['administrator'],
component: SettingsContent,
props: {
headerTitle: 'BILLING_SETTINGS.TITLE',
icon: 'credit-card-person',
showNewButton: false,
},
children: [
{
path: '',
name: 'billing_settings_index',
component: Index,
roles: ['administrator'],
},
],
},
],
};

View file

@ -0,0 +1,38 @@
<template>
<div class="manage-subscription">
<div>
<h6>{{ title }}</h6>
<p class="manage-subscription--description">
{{ description }}
</p>
</div>
<div>
<woot-button variant="smooth" :icon="buttonIcon" @click="$emit('click')">
{{ buttonLabel }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
buttonIcon: {
type: String,
default: 'edit',
},
},
};
</script>

View file

@ -13,6 +13,7 @@ import teams from './teams/teams.routes';
import attributes from './attributes/attributes.routes';
import automation from './automation/automation.routes';
import store from '../../../store';
import billing from './billing/billing.routes';
export default {
routes: [
@ -29,16 +30,17 @@ export default {
},
...account.routes,
...agent.routes,
...attributes.routes,
...automation.routes,
...billing.routes,
...campaigns.routes,
...canned.routes,
...inbox.routes,
...integrationapps.routes,
...integrations.routes,
...labels.routes,
...profile.routes,
...reports.routes,
...teams.routes,
...campaigns.routes,
...integrationapps.routes,
...attributes.routes,
...automation.routes,
],
};

View file

@ -1,9 +1,8 @@
/* eslint no-console: 0 */
/* eslint no-param-reassign: 0 */
/* eslint no-shadow: 0 */
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AccountAPI from '../../api/account';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
const state = {
records: [],
@ -11,6 +10,7 @@ const state = {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
},
};
@ -60,6 +60,29 @@ export const actions = {
throw error;
}
},
checkout: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
const response = await EnterpriseAccountAPI.checkout();
window.location = response.data.redirect_url;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.subscription();
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
};
export const mutations = {

View file

@ -63,6 +63,7 @@
"M10 13.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5z",
"M4 6h1v6.5A2.5 2.5 0 0 0 7.5 15H14v1a2 2 0 0 1-2 2H5.5A3.5 3.5 0 0 1 2 14.5V8a2 2 0 0 1 2-2z"
],
"credit-card-person-outline": "M2 7.25A3.25 3.25 0 0 1 5.25 4h13.5A3.25 3.25 0 0 1 22 7.25V10h-.258A3.74 3.74 0 0 0 20.5 7.455V7.25a1.75 1.75 0 0 0-1.75-1.75H5.25A1.75 1.75 0 0 0 3.5 7.25v.25h11.95c-.44.409-.782.922-.987 1.5H3.5v5.75c0 .966.784 1.75 1.75 1.75h6.78c.06.522.217 1.028.458 1.5H5.25A3.25 3.25 0 0 1 2 14.75v-7.5Zm21 8.25a1.5 1.5 0 0 0-1.5-1.5h-7a1.5 1.5 0 0 0-1.5 1.5v.5c0 1.971 1.86 4 5 4 3.14 0 5-2.029 5-4v-.5Zm-2.25-5.25a2.75 2.75 0 1 0-5.5 0 2.75 2.75 0 0 0 5.5 0Z",
"delete-outline": "M12 1.75a3.25 3.25 0 0 1 3.245 3.066L15.25 5h5.25a.75.75 0 0 1 .102 1.493L20.5 6.5h-.796l-1.28 13.02a2.75 2.75 0 0 1-2.561 2.474l-.176.006H8.313a2.75 2.75 0 0 1-2.714-2.307l-.023-.174L4.295 6.5H3.5a.75.75 0 0 1-.743-.648L2.75 5.75a.75.75 0 0 1 .648-.743L3.5 5h5.25A3.25 3.25 0 0 1 12 1.75Zm6.197 4.75H5.802l1.267 12.872a1.25 1.25 0 0 0 1.117 1.122l.127.006h7.374c.6 0 1.109-.425 1.225-1.002l.02-.126L18.196 6.5ZM13.75 9.25a.75.75 0 0 1 .743.648L14.5 10v7a.75.75 0 0 1-1.493.102L13 17v-7a.75.75 0 0 1 .75-.75Zm-3.5 0a.75.75 0 0 1 .743.648L11 10v7a.75.75 0 0 1-1.493.102L9.5 17v-7a.75.75 0 0 1 .75-.75Zm1.75-6a1.75 1.75 0 0 0-1.744 1.606L10.25 5h3.5A1.75 1.75 0 0 0 12 3.25Z",
"dismiss-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17Zm3.446 4.897.084.073a.75.75 0 0 1 .073.976l-.073.084L13.061 12l2.47 2.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-2.47 2.47a.75.75 0 0 1-.976.072l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084L10.939 12l-2.47-2.47a.75.75 0 0 1-.072-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l2.47-2.47a.75.75 0 0 1 .976-.072Z",
"dismiss-outline": "m4.397 4.554.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l6.47-6.47a.75.75 0 1 1 1.06 1.061L13.061 12l6.47 6.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-6.47 6.47a.75.75 0 0 1-1.06-1.061L10.939 12l-6.47-6.47a.75.75 0 0 1-.072-.976l.073-.084-.073.084Z",

View file

@ -15,6 +15,7 @@ const {
TERMS_URL: termsURL,
WIDGET_BRAND_URL: widgetBrandURL,
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
DEPLOYMENT_ENV: deploymentEnv,
} = window.globalConfig || {};
const state = {
@ -23,6 +24,7 @@ const state = {
appVersion,
brandName,
chatwootInboxToken,
deploymentEnv,
createNewAccountFromDashboard,
directUploadsEnabled: directUploadsEnabled === 'true',
disableUserProfileUpdate: disableUserProfileUpdate === 'true',
@ -38,6 +40,7 @@ const state = {
export const getters = {
get: $state => $state,
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
};
export const actions = {};

View file

@ -10,4 +10,12 @@ class AccountPolicy < ApplicationPolicy
def update_active_at?
true
end
def subscription?
@account_user.administrator?
end
def checkout?
@account_user.administrator?
end
end

View file

@ -1,6 +1,11 @@
json.auto_resolve_duration resource.auto_resolve_duration
json.created_at resource.created_at
json.custom_attributes resource.custom_attributes
if resource.custom_attributes.present?
json.custom_attributes do
json.plan_name resource.custom_attributes['plan_name']
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
end
end
json.custom_email_domain_enabled @account.custom_email_domain_enabled
json.domain @account.domain
json.features @account.enabled_features

View file

@ -68,3 +68,7 @@
- name: CSML_BOT_API_KEY
value:
locked: false
- name: CHATWOOT_CLOUD_PLANS
value:
- name: DEPLOYMENT_ENV
value: self-hosted

View file

@ -224,6 +224,23 @@ Rails.application.routes.draw do
end
end
if ChatwootApp.enterprise?
namespace :enterprise, defaults: { format: 'json' } do
namespace :api do
namespace :v1 do
resources :accounts do
member do
post :checkout
post :subscription
end
end
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
end
end
end
end
# ----------------------------------------------------------------------
# Routes for platform APIs
namespace :platform, defaults: { format: 'json' } do

View file

@ -0,0 +1,47 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
before_action :fetch_account
before_action :check_authorization
def subscription
Enterprise::CreateStripeCustomerJob.perform_later(@account) if stripe_customer_id.blank?
head :no_content
end
def checkout
return create_stripe_billing_session(stripe_customer_id) if stripe_customer_id.present?
render_invalid_billing_details
end
private
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def stripe_customer_id
@account.custom_attributes['stripe_customer_id']
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end
def create_stripe_billing_session(customer_id)
session = Enterprise::Billing::CreateSessionService.new.create_session(customer_id)
render_redirect_url(session.url)
end
def render_redirect_url(redirect_url)
render json: { redirect_url: redirect_url }
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
end

View file

@ -0,0 +1,21 @@
class Enterprise::Api::V1::Webhooks::StripeController < ActionController::API
def process_payload
# Get the event payload and signature
payload = request.body.read
sig_header = request.headers['Stripe-Signature']
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
head :bad_request
return
end
# We've successfully processed the event without blowing up
head :ok
end
end

View file

@ -0,0 +1,7 @@
class Enterprise::CreateStripeCustomerJob < ApplicationJob
queue_as :default
def perform(account)
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
end

View file

@ -1,13 +1,18 @@
module Enterprise::Account
def usage_limits
{
agents: get_limits(:agents).to_i,
agents: agent_limits,
inboxes: get_limits(:inboxes).to_i
}
end
private
def agent_limits
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
self[:limits][limit_name.to_s] || GlobalConfig.get(config_name)[config_name] || ChatwootApp.max_limit

View file

@ -0,0 +1,10 @@
class Enterprise::Billing::CreateSessionService
def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL'))
Stripe::BillingPortal::Session.create(
{
customer: customer_id,
return_url: return_url
}
)
end
end

View file

@ -0,0 +1,53 @@
class Enterprise::Billing::CreateStripeCustomerService
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
def perform
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
end
private
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
if customer_id.blank?
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
customer_id = customer.id
end
customer_id
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
def billing_email
account.administrators.first.email
end
def default_plan
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
@default_plan ||= installation_config.value.first
end
def price_id
price_ids = default_plan['price_ids']
price_ids.first
end
end

View file

@ -0,0 +1,41 @@
class Enterprise::Billing::HandleStripeEventService
def perform(event:)
ensure_event_context(event)
case @event.type
when 'customer.subscription.updated'
plan = find_plan(subscription['plan']['product'])
account.update(
custom_attributes: {
stripe_customer_id: subscription.customer,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: plan['name'],
subscribed_quantity: subscription['quantity']
}
)
when 'customer.subscription.deleted'
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
end
private
def ensure_event_context(event)
@event = event
end
def subscription
@subscription ||= @event.data.object
end
def account
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def find_plan(plan_id)
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
installation_config.value.find { |config| config['product_id'].include?(plan_id) }
end
end

View file

@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe 'Enterprise Billing APIs', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/subscription", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'when it is an agent' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'enqueues a job' do
expect do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account)
end
it 'does not enqueues a job if customer id is present' do
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
expect do
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account)
end
end
end
end
describe 'POST /enterprise/api/v1/accounts/{account.id}/checkout' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'when it is an agent' do
it 'returns unauthorized' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin and the stripe customer id is not present' do
it 'returns error' do
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Please subscribe to a plan before viewing the billing details')
end
end
context 'when it is an admin and the stripe customer is present' do
it 'calls create session' do
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
create_session_service = double
allow(Enterprise::Billing::CreateSessionService).to receive(:new).and_return(create_session_service)
allow(create_session_service).to receive(:create_session).and_return(create_session_service)
allow(create_session_service).to receive(:url).and_return('https://billing.stripe.com/random_string')
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['redirect_url']).to eq('https://billing.stripe.com/random_string')
end
end
end
end
end

View file

@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
include ActiveJob::TestHelper
subject(:job) { described_class.perform_later(account) }
let(:account) { create(:account) }
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account)
.on_queue('default')
end
it 'executes perform' do
create_stripe_customer_service = double
allow(Enterprise::Billing::CreateStripeCustomerService)
.to receive(:new)
.with(account: account)
.and_return(create_stripe_customer_service)
allow(create_stripe_customer_service).to receive(:perform)
perform_enqueued_jobs { job }
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
end
end

View file

@ -28,5 +28,15 @@ RSpec.describe Account do
}
)
end
it 'returns limits based on subscription' do
account.update(limits: { agents: 10 }, custom_attributes: { subscribed_quantity: 5 })
expect(account.usage_limits).to eq(
{
agents: 5,
inboxes: ChatwootApp.max_limit
}
)
end
end
end

View file

@ -0,0 +1,22 @@
require 'rails_helper'
describe Enterprise::Billing::CreateSessionService do
subject(:create_session_service) { described_class }
describe '#perform' do
it 'calls stripe billing portal session' do
customer_id = 'cus_random_number'
return_url = 'https://www.chatwoot.com'
allow(Stripe::BillingPortal::Session).to receive(:create).with({ customer: customer_id, return_url: return_url })
create_session_service.new.create_session(customer_id, return_url)
expect(Stripe::BillingPortal::Session).to have_received(:create).with(
{
customer: customer_id,
return_url: return_url
}
)
end
end
end

View file

@ -0,0 +1,81 @@
require 'rails_helper'
describe Enterprise::Billing::CreateStripeCustomerService do
subject(:create_stripe_customer_service) { described_class }
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).not_to have_received(:create)
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
end
end