diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 09b648a6f..6b2f9ea75 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0762c3d91..d2960a699 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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, diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 36c789b52..bd144b0df 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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 diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js new file mode 100644 index 000000000..bc0d51dfc --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -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(); diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js new file mode 100644 index 000000000..28fd83cd1 --- /dev/null +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -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' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 80b2486e6..fb0947de7 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -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', diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js index 0b78e8a21..990b35d4a 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js @@ -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', diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue index 4b19a3ec3..4f0a5e38d 100644 --- a/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue +++ b/app/javascript/dashboard/components/layout/sidebarComponents/Secondary.vue @@ -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 { diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 0307abe56..53dc6ffaa 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -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", diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue new file mode 100644 index 000000000..a7c330527 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -0,0 +1,116 @@ + + + + + {{ $t('BILLING_SETTINGS.NO_BILLING_USER') }} + + + + {{ $t('BILLING_SETTINGS.CURRENT_PLAN.TITLE') }} + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js new file mode 100644 index 000000000..db28fb844 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/billing.routes.js @@ -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'], + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue new file mode 100644 index 000000000..6f8ade8a0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/BillingItem.vue @@ -0,0 +1,38 @@ + + + + {{ title }} + + {{ description }} + + + + + {{ buttonLabel }} + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index 110f26fba..8f3de8f4e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -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, ], }; diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index a30e64e46..32bca04a8 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -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 = { diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index ed818374d..08a452f88 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -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", diff --git a/app/javascript/shared/store/globalConfig.js b/app/javascript/shared/store/globalConfig.js index d1f7aa64d..36babd0d9 100644 --- a/app/javascript/shared/store/globalConfig.js +++ b/app/javascript/shared/store/globalConfig.js @@ -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 = {}; diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index ec5799587..f97418339 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -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 diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index bc28f7a27..ddf366597 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -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 diff --git a/config/installation_config.yml b/config/installation_config.yml index ec3b0b635..74a45b1c5 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -68,3 +68,7 @@ - name: CSML_BOT_API_KEY value: locked: false +- name: CHATWOOT_CLOUD_PLANS + value: +- name: DEPLOYMENT_ENV + value: self-hosted diff --git a/config/routes.rb b/config/routes.rb index 9e0acc196..222ac8b83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb new file mode 100644 index 000000000..bb24b5504 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb new file mode 100644 index 000000000..0dceeb346 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v1/webhooks/stripe_controller.rb @@ -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 diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb new file mode 100644 index 000000000..6b90cbec5 --- /dev/null +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -0,0 +1,7 @@ +class Enterprise::CreateStripeCustomerJob < ApplicationJob + queue_as :default + + def perform(account) + Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + end +end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index 99defb6ea..20a4dbfcd 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/create_session_service.rb b/enterprise/app/services/enterprise/billing/create_session_service.rb new file mode 100644 index 000000000..ad4f8f45b --- /dev/null +++ b/enterprise/app/services/enterprise/billing/create_session_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb new file mode 100644 index 000000000..76c09a27a --- /dev/null +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb new file mode 100644 index 000000000..0942bbe1f --- /dev/null +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 diff --git a/spec/enterprise/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb similarity index 100% rename from spec/enterprise/controllers/api/v1/accounts/inboxes_controller_spec.rb rename to spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb new file mode 100644 index 000000000..57ef52dbb --- /dev/null +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts_controller_spec.rb @@ -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 diff --git a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb new file mode 100644 index 000000000..b2c945800 --- /dev/null +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -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 diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index 6f0fa5aa7..16a664ef5 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb new file mode 100644 index 000000000..0c7fabaa3 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/create_session_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb new file mode 100644 index 000000000..95edf73a1 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -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
{{ $t('BILLING_SETTINGS.NO_BILLING_USER') }}
+ {{ description }} +