Compare commits

..
41 changed files with 1014 additions and 648 deletions
+3 -5
View File
@@ -72,11 +72,9 @@ class ContactIdentifyAction
def merge_contacts?(existing_contact, key)
return if existing_contact.blank?
# Never merge into a contact that already owns an identifier unless the request
# supplies the matching one. This also covers the blank-identifier case: an
# unverified/anonymous request must not take over an identified contact just by
# matching its email or phone_number. Merges into non-identified contacts
# (anonymous dedup) are still allowed.
return true if params[:identifier].blank?
# we want to prevent merging contacts with different identifiers
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
# we will remove attribute from update list
@attributes_to_update.delete(key)
-12
View File
@@ -22,16 +22,4 @@ module BillingHelper
def agents(account)
account.users.count
end
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
def subscription_period_end(subscription)
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
end
def subscription_ends_on(subscription)
period_end = subscription_period_end(subscription)
return if period_end.blank?
Time.zone.at(period_end)
end
end
+1 -1
View File
@@ -11,7 +11,7 @@ saml:
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
description: 'Apply your own branding to this installation, including email template layout customization.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-paint-brush-line'
config_key: 'custom_branding'
+3 -1
View File
@@ -1,6 +1,8 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
return false if params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?
params[:controller].in? %w[super_admin/settings super_admin/app_configs super_admin/email_layouts]
end
def settings_pages
@@ -14,10 +14,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}subscription`);
}
selectBillingCurrency(currency) {
return axios.post(`${this.url}select_billing_currency`, { currency });
}
getLimits() {
return axios.get(`${this.url}limits`);
}
@@ -31,11 +27,6 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
// Topup packages for the account's billing currency.
getTopupOptions() {
return axios.get(`${this.url}topup_options`);
}
}
export default new EnterpriseAccountAPI();
@@ -1,35 +0,0 @@
// Single source of truth for billing currencies on the frontend.
// Adding a currency = one entry in BILLING_CURRENCY_CONFIG, add the code to
// SUPPORTED_BILLING_CURRENCIES, and add its label key under
// BILLING_SETTINGS.CURRENCY.OPTIONS in the locale files.
export const DEFAULT_BILLING_CURRENCY = 'usd';
// Order here drives the order of the currency toggle in the UI.
export const SUPPORTED_BILLING_CURRENCIES = ['usd', 'brl'];
export const BILLING_CURRENCY_CONFIG = {
usd: {
code: 'usd',
intlLocale: 'en-US',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.USD',
},
brl: {
code: 'brl',
intlLocale: 'pt-BR',
i18nLabelKey: 'BILLING_SETTINGS.CURRENCY.OPTIONS.BRL',
},
};
export const getCurrencyConfig = code =>
BILLING_CURRENCY_CONFIG[(code || DEFAULT_BILLING_CURRENCY).toLowerCase()] ||
BILLING_CURRENCY_CONFIG[DEFAULT_BILLING_CURRENCY];
export const formatCurrencyAmount = (amount, code, options = {}) => {
const { intlLocale, code: currencyCode } = getCurrencyConfig(code);
return new Intl.NumberFormat(intlLocale, {
style: 'currency',
currency: currencyCode.toUpperCase(),
...options,
}).format(amount);
};
@@ -467,18 +467,7 @@
"TITLE": "Current Plan",
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
"SEAT_COUNT": "Number of seats",
"RENEWS_ON": "Renews on",
"CURRENCY": "Currency"
},
"CURRENCY": {
"SELECT": {
"TITLE": "Choose your billing currency",
"DESCRIPTION": "Select the currency you'd like to be billed in. This can't be changed once your subscription is created."
},
"OPTIONS": {
"USD": "US Dollar (USD)",
"BRL": "Brazilian Real (BRL)"
}
"RENEWS_ON": "Renews on"
},
"VIEW_PRICING": "View Pricing",
"MANAGE_SUBSCRIPTION": {
@@ -514,7 +503,6 @@
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"RETRY": "Retry",
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
"CONFIRM": {
@@ -15,8 +15,6 @@ import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
import { getCurrencyConfig } from 'dashboard/constants/billing';
import { useI18n } from 'vue-i18n';
const router = useRouter();
const { currentAccount, isOnChatwootCloud } = useAccount();
@@ -31,7 +29,6 @@ const {
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
const { t } = useI18n();
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
@@ -39,10 +36,6 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
// Currency selection shown to new accounts whose locale supports a non-USD currency.
const currencySelectionRequired = ref(false);
const currencyOptions = ref([]);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
@@ -68,13 +61,6 @@ const subscribedQuantity = computed(() => {
return customAttributes.value.subscribed_quantity;
});
const billingCurrency = computed(() => {
if (!customAttributes.value.billing_currency) return '';
return t(
getCurrencyConfig(customAttributes.value.billing_currency).i18nLabelKey
);
});
const subscriptionRenewsOn = computed(() => {
if (!customAttributes.value.subscription_ends_on) return '';
const endDate = new Date(customAttributes.value.subscription_ends_on);
@@ -92,9 +78,7 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
const data = await store.dispatch('accounts/subscription');
currencySelectionRequired.value = !!data?.currency_selection_required;
currencyOptions.value = data?.currency_options || [];
await store.dispatch('accounts/subscription');
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
@@ -113,9 +97,6 @@ const handleBillingPageLogic = async () => {
// If cloud user, fetch account details first
await fetchAccountDetails();
// Waiting on the user to pick a billing currency — don't auto-refresh.
if (currencySelectionRequired.value) return;
// If still no billing plan after fetch
if (!hasABillingPlan.value) {
// If we haven't attempted refresh yet, do it once
@@ -137,13 +118,6 @@ const handleBillingPageLogic = async () => {
}
};
const onSelectCurrency = async code => {
await store.dispatch('accounts/selectBillingCurrency', code);
currencySelectionRequired.value = false;
// Currency stored and customer creation kicked off — resume the standard wait flow.
await handleBillingPageLogic();
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
};
@@ -174,9 +148,7 @@ onMounted(handleBillingPageLogic);
? $t('BILLING_SETTINGS.NO_BILLING_USER')
: $t('ATTRIBUTES_MGMT.LOADING')
"
:no-records-found="
!hasABillingPlan && !isWaitingForBilling && !currencySelectionRequired
"
:no-records-found="!hasABillingPlan && !isWaitingForBilling"
:no-records-message="$t('BILLING_SETTINGS.NO_BILLING_USER')"
>
<template #header>
@@ -188,30 +160,7 @@ onMounted(handleBillingPageLogic);
/>
</template>
<template #body>
<section v-if="currencySelectionRequired" class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.CURRENCY.SELECT.TITLE')"
:description="$t('BILLING_SETTINGS.CURRENCY.SELECT.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
v-for="code in currencyOptions"
:key="code"
sm
solid
blue
:is-loading="uiFlags.isCheckoutInProcess"
:disabled="uiFlags.isCheckoutInProcess"
@click="onSelectCurrency(code)"
>
{{ $t(getCurrencyConfig(code).i18nLabelKey) }}
</ButtonV4>
</div>
</template>
</BillingCard>
</section>
<section v-else class="grid gap-4">
<section class="grid gap-4">
<BillingCard
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
@@ -239,11 +188,6 @@ onMounted(handleBillingPageLogic);
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
:value="subscriptionRenewsOn"
/>
<DetailItem
v-if="billingCurrency"
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.CURRENCY')"
:value="billingCurrency"
/>
</div>
</BillingCard>
<BillingCard
@@ -1,6 +1,4 @@
<script setup>
import { formatCurrencyAmount } from 'dashboard/constants/billing';
defineProps({
credits: {
type: Number,
@@ -35,7 +33,11 @@ const formatCredits = credits => {
};
const formatAmount = (amount, currency) => {
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
@@ -4,18 +4,20 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import {
formatCurrencyAmount,
DEFAULT_BILLING_CURRENCY,
} from 'dashboard/constants/billing';
const emit = defineEmits(['success']);
const emit = defineEmits(['close', 'success']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const STEP_SELECT = 'select';
const STEP_CONFIRM = 'confirm';
@@ -25,20 +27,16 @@ const selectedCredits = ref(null);
const isLoading = ref(false);
const currentStep = ref(STEP_SELECT);
// Topup packages come from the backend for the account's billing currency.
const topupOptions = ref([]);
const optionsCurrency = ref(DEFAULT_BILLING_CURRENCY);
const isFetchingOptions = ref(false);
const fetchError = ref(false);
const selectedOption = computed(() => {
return topupOptions.value.find(o => o.credits === selectedCredits.value);
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const formattedAmount = computed(() => {
if (!selectedOption.value) return '';
const { amount, currency } = selectedOption.value;
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: selectedOption.value.currency.toUpperCase(),
}).format(selectedOption.value.amount);
});
const formattedCredits = computed(() => {
@@ -66,44 +64,24 @@ const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const selectDefaultOption = () => {
const popularOption = topupOptions.value.find(
const open = () => {
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value =
popularOption?.credits || topupOptions.value[0]?.credits || null;
};
const fetchOptions = async () => {
isFetchingOptions.value = true;
fetchError.value = false;
try {
const { data } = await EnterpriseAccountAPI.getTopupOptions();
topupOptions.value = data.options ?? [];
optionsCurrency.value = (
data.currency || DEFAULT_BILLING_CURRENCY
).toLowerCase();
selectDefaultOption();
} catch {
fetchError.value = true;
topupOptions.value = [];
} finally {
isFetchingOptions.value = false;
}
};
const open = () => {
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
currentStep.value = STEP_SELECT;
isLoading.value = false;
selectedCredits.value = null;
dialogRef.value?.open();
fetchOptions();
};
const close = () => {
dialogRef.value?.close();
};
const handleClose = () => {
emit('close');
};
const goToConfirmStep = () => {
if (!selectedOption.value) return;
currentStep.value = STEP_CONFIRM;
@@ -149,58 +127,32 @@ defineExpose({ open, close });
:width="dialogWidth"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<!-- Step 1: Select Credits Package -->
<template v-if="currentStep === STEP_SELECT">
<div
v-if="isFetchingOptions"
class="flex items-center justify-center gap-2 py-10"
>
<Spinner />
<span class="text-sm text-n-slate-11">{{
$t('BILLING_SETTINGS.TOPUP.LOADING')
}}</span>
</div>
<div
v-else-if="fetchError"
class="flex flex-col items-center justify-center gap-3 py-10"
>
<p class="text-sm text-n-slate-11">
{{ $t('BILLING_SETTINGS.TOPUP.FETCH_ERROR') }}
</p>
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.RETRY')"
@click="fetchOptions"
<template v-if="currentStep === 'select'">
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<template v-else>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in topupOptions"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
</template>
<!-- Step 2: Confirm Purchase -->
@@ -226,7 +178,7 @@ defineExpose({ open, close });
<template #footer>
<!-- Step 1 Footer -->
<div
v-if="currentStep === STEP_SELECT"
v-if="currentStep === 'select'"
class="flex items-center justify-between w-full gap-3"
>
<Button
@@ -240,7 +192,7 @@ defineExpose({ open, close });
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits || isFetchingOptions || fetchError"
:disabled="!selectedCredits"
@click="goToConfirmStep"
/>
</div>
@@ -144,20 +144,7 @@ export const actions = {
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
const response = await EnterpriseAccountAPI.subscription();
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
selectBillingCurrency: async ({ commit }, currency) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.selectBillingCurrency(currency);
await EnterpriseAccountAPI.subscription();
} catch (error) {
throwErrorMessage(error);
} finally {
+1 -1
View File
@@ -20,7 +20,7 @@ class EmailTemplate < ApplicationRecord
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account }
validates :name, uniqueness: { scope: [:account, :template_type, :locale] }
def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options
-8
View File
@@ -23,10 +23,6 @@ class AccountPolicy < ApplicationPolicy
@account_user.administrator?
end
def select_billing_currency?
@account_user.administrator?
end
def checkout?
@account_user.administrator?
end
@@ -38,8 +34,4 @@ class AccountPolicy < ApplicationPolicy
def topup_checkout?
@account_user.administrator?
end
def topup_options?
@account_user.administrator?
end
end
@@ -35,6 +35,8 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
def find_templates(name, prefix, partial, *_args)
@template_name = name
@template_type = prefix.include?('layout') ? 'layout' : 'content'
@_prefix = prefix
@_details = _args.first || {}
@db_template = find_db_template
return [] if @db_template.blank?
@@ -60,11 +62,23 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
def find_account_template
return unless Current.account
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
find_template_for(Current.account)
end
def find_installation_template
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
find_template_for(nil)
end
def find_template_for(account)
@@model.find_by(name: db_template_name, template_type: @template_type, locale: locale_from_details, account: account) ||
@@model.find_by(name: @template_name, template_type: @template_type, locale: locale_from_details, account: account)
end
def locale_from_details
return :en if @_details.blank?
locale = Array(@_details[:locale]).first
EmailTemplate.locales.key?(locale.to_s) ? locale : :en
end
# Build path with eventual prefix
@@ -72,6 +86,12 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
prefix.present? ? "#{prefix}/#{@template_name}" : @template_name
end
def db_template_name
return @template_name if @template_type == 'layout'
build_path(@_prefix)
end
# returns a path depending if its a partial or template
# params path: path/to/file.ext partial: true/false
# the function appends _to make the file name _file.ext if partial: true
@@ -6,7 +6,6 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.billing_currency resource.billing_currency if resource.respond_to?(:billing_currency) && Enterprise::Billing::Currencies.enabled?
json.website resource.custom_attributes['website'] if resource.custom_attributes['website'].present?
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
+59 -53
View File
@@ -14,66 +14,72 @@
}
</style>
<section class="main-content__body">
<section class="main-content__body relative">
<%= render partial: 'email_layout' if @config == 'custom_branding' && ChatwootApp.enterprise? %>
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |key| %>
<div class="flex mb-8">
<div class="field-unit__label">
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
<% @allowed_configs.each do |key| %>
<div class="flex mb-8">
<div class="field-unit__label">
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
<%= form.select "app_config[#{key}]",
@installation_configs[key]['options'].map { |val, label| [label, val] },
{ selected: @app_config[key] },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
<%if @installation_configs[key]&.dig('description').present? %>
<p class="pt-2 text-xs italic text-slate-400">
<%= @installation_configs[key]&.dig('description') %>
</p>
<% end %>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
<%= form.select "app_config[#{key}]",
@installation_configs[key]['options'].map { |val, label| [label, val] },
{ selected: @app_config[key] },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
<% if @installation_configs[key]&.dig('description').present? %>
<p class="pt-2 text-xs italic text-slate-400">
<%= @installation_configs[key]&.dig('description') %>
</p>
<% end %>
</div>
</div>
</div>
<% end %>
<% end %>
<div class="form-actions">
<%= form.submit "Submit" %>
</div>
<% end %>
</section>
<% content_for :javascript do %>
@@ -1,5 +1,5 @@
<li class="px-4 mb-1">
<% text_class_name = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<% text_class_name = local_assigns.fetch(:active, current_page?(url)) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to(url, class: text_class_name + " -ml-1 focus:outline-none cursor-pointer flex items-center px-2 py-1.5 text-slate-800 cursor-pointer hover:text-woot-500 hover:bg-slate-25 rounded-lg") do %>
<svg width="16" height="16"><use xlink:href="#<%= icon %>" /></svg>
<span class="ml-2 text-sm"><%= label %></span>
@@ -33,12 +33,15 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics", "email_layouts"].include? resource.resource %>
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
<% resource_url = resource_index_route(resource) %>
<% active = current_page?(resource_url) || (resource.resource == 'accounts' && params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?) %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
url: resource_url,
label: display_resource_name(resource),
active: active,
}
%>
-11
View File
@@ -253,17 +253,6 @@
display_title: 'Cloud Plans'
value:
description: 'Config to store stripe plans for cloud'
- name: CAPTAIN_TOPUP_OPTIONS
display_title: 'Captain Topup Options'
value: {}
description: 'Currency-keyed AI credit top-up packages, e.g. {"usd":[{"credits":1000,"amount":20.0}],"brl":[{"credits":1000,"amount":100.0}]}'
type: code
- name: ENABLE_MULTI_CURRENCY_BILLING
display_title: 'Enable Multi-currency Billing'
value: false
locked: false
description: 'Bill new accounts in their local currency (e.g. BRL) and show currency-aware credit top-ups; when off, everyone is billed in USD'
type: boolean
- name: MARKETING_CONVERSION_TRACKING_CONFIG
value:
display_title: 'Marketing Conversion Tracking Config'
-3
View File
@@ -173,9 +173,6 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
billing:
invalid_currency: Invalid billing currency
currency_locked: Billing currency cannot be changed after billing has been set up
topup:
credits_required: Credits amount is required
invalid_credits: Invalid credits amount
+2 -2
View File
@@ -528,11 +528,9 @@ Rails.application.routes.draw do
member do
post :checkout
post :subscription
post :select_billing_currency
get :limits
post :toggle_deletion
post :topup_checkout
get :topup_options
end
end
end
@@ -687,6 +685,7 @@ Rails.application.routes.draw do
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
post :seed, on: :member
post :reset_cache, on: :member
resource :email_layout, only: [:edit, :update, :destroy] if ChatwootApp.enterprise?
end
resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
delete :avatar, on: :member, action: :destroy_avatar
@@ -704,6 +703,7 @@ Rails.application.routes.draw do
resource :settings, only: [:show] do
get :refresh, on: :collection
end
resource :email_layout, only: [:edit, :update, :destroy] if ChatwootApp.enterprise?
# resources that doesn't appear in primary navigation in super admin
resources :account_users, only: [:new, :create, :show, :destroy]
@@ -0,0 +1,9 @@
class UpdateEmailTemplatesUniqueIndexForLocaleAndType < ActiveRecord::Migration[7.1]
def change
remove_index :email_templates, name: 'index_email_templates_on_name_and_account_id'
add_index :email_templates,
[:name, :account_id, :template_type, :locale],
unique: true,
name: 'index_email_templates_on_name_account_type_locale'
end
end
+1 -1
View File
@@ -859,7 +859,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.integer "locale", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
t.index ["name", "account_id", "template_type", "locale"], name: "index_email_templates_on_name_account_type_locale", unique: true
end
create_table "folders", force: :cascade do |t|
@@ -2,24 +2,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options]
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def subscription
return render json: currency_selection_payload if @account.billing_currency_selection_required?
ensure_stripe_customer
head :no_content
end
def select_billing_currency
return render_could_not_create_error(I18n.t('errors.billing.currency_locked')) if currency_locked?
return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless @account.billing_currency_selection_required?
currency = Enterprise::Billing::Currencies.normalize(params[:currency])
return render_could_not_create_error(I18n.t('errors.billing.invalid_currency')) unless Enterprise::Billing::Currencies.supported?(currency)
@account.update!(custom_attributes: @account.custom_attributes.merge('billing_currency' => currency))
ensure_stripe_customer
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
head :no_content
end
@@ -82,32 +71,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
render_could_not_create_error(e.message)
end
def topup_options
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
render json: { id: @account.id, currency: @account.billing_currency, options: service.available_options }
end
private
def check_cloud_env
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def ensure_stripe_customer
return if stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
@account.update!(custom_attributes: @account.custom_attributes.merge('is_creating_customer' => true))
Enterprise::CreateStripeCustomerJob.perform_later(@account)
end
def currency_selection_payload
{
currency_selection_required: true,
currency_options: Enterprise::Billing::Currencies::SUPPORTED,
suggested_currency: Enterprise::Billing::Currencies.for_locale(@account.locale)
}
end
def default_limits
{
'conversation' => {},
@@ -129,12 +98,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
@account.custom_attributes['stripe_customer_id']
end
# Currency is fixed once a customer exists or creation is already in flight,
# so a second click can't bill a different currency than setup started with.
def currency_locked?
stripe_customer_id.present? || @account.custom_attributes['is_creating_customer'].present?
end
def mark_for_deletion
reason = 'manual_deletion'
@@ -0,0 +1,280 @@
class SuperAdmin::EmailLayoutsController < SuperAdmin::EnterpriseBaseController
before_action :set_account
before_action :set_custom_templates
before_action :set_template_selection
before_action :set_email_layout
before_action :ensure_custom_branding_access!
def edit
@show_template_editor = editor_requested?
return unless @show_template_editor
@layout_body = @email_layout&.body || inherited_template_body
set_preview_context
end
def update
@layout_body = layout_params[:body].to_s
validation_error = validate_layout_body(@layout_body)
return render_edit_with_alert(validation_error) if validation_error.present?
return render_edit_with_alert(conflict_error) if conflicting_template_exists?
@email_layout ||= EmailTemplate.new(name: template_name, template_type: template_type, locale: template_locale, account: @account)
@email_layout.body = @layout_body
if @email_layout.save
# rubocop:disable Rails/I18nLocaleTexts
redirect_to edit_email_layout_path, notice: 'Email layout updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
else
render_edit_with_alert(@email_layout.errors.full_messages.join(', '))
end
end
def destroy
@email_layout&.destroy
redirect_to email_layout_overview_path, notice: reset_notice
end
private
def set_account
@account = Account.find(params[:account_id]) if params[:account_id].present?
end
def set_custom_templates
@custom_templates = EmailTemplate.where(account: @account).order(:template_type, :name, :locale)
end
def set_template_selection
@template_type = selected_template_type
@template_name = selected_template_name
@template_locale = selected_template_locale
@template_options = template_options
@locale_options = locale_options
end
def set_email_layout
@email_layout = EmailTemplate.find_by(name: template_name, template_type: template_type, locale: template_locale, account: @account)
end
attr_reader :template_name, :template_type, :template_locale
def default_layout_body
Rails.root.join('app/views/layouts/mailer/base.liquid').read
end
def default_content_body
content_template_path.read
end
def inherited_template_body
return global_template_body || default_template_body if @account.present?
default_template_body
end
def default_template_body
return default_layout_body if template_type == 'layout'
default_content_body
end
def global_template_body
EmailTemplate.find_by(name: template_name, template_type: template_type, locale: template_locale, account: nil)&.body
end
def layout_params
params.require(:email_layout).permit(:body, :template_type, :template_name, :locale)
end
def validate_layout_body(body)
return 'Template body cannot be blank' if body.blank?
return 'Layout must include {{ content_for_layout }}' if template_type == 'layout' && !body.match?(/\{\{\s*content_for_layout\s*\}\}/)
Liquid::Template.parse(body)
nil
rescue Liquid::SyntaxError => e
"Liquid syntax error: #{e.message}"
end
def render_edit_with_alert(message)
flash.now[:alert] = message
@show_template_editor = true
set_preview_context
render :edit, status: :unprocessable_entity
end
def set_preview_context
@preview_html = render_preview(@layout_body)
@supported_variables = supported_variables
@template_status = template_status
@preview_config = preview_config
rescue Liquid::SyntaxError
@preview_html = ''
@supported_variables = supported_variables
@template_status = template_status
@preview_config = preview_config
end
def render_preview(body)
assigns = {
'content_for_layout' => preview_content,
'global_config' => GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
}
assigns['account'] = { 'name' => @account.name } if @account.present?
rendered_body = Liquid::Template.parse(body).render(assigns)
return rendered_body if template_type == 'layout'
Liquid::Template.parse(default_layout_body).render(assigns.merge('content_for_layout' => rendered_body))
end
def preview_content
<<~HTML
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; padding: 0;">
<h1 style="font-size: 20px; margin: 0 0 12px;">Sample notification</h1>
<p style="margin: 0 0 16px;">This preview shows how notification content renders inside your email layout.</p>
<a href="#" style="display: inline-block; background: #1f93ff; color: #fff; padding: 10px 14px; border-radius: 6px; text-decoration: none;">View conversation</a>
</td>
</tr>
HTML
end
def preview_config
brand_config = GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
{
templateType: template_type,
defaultLayout: default_layout_body,
previewContent: preview_content,
brandName: brand_config['BRAND_NAME'].to_s,
brandUrl: brand_config['BRAND_URL'].to_s,
accountName: @account&.name.to_s
}
end
def supported_variables
variables = []
if template_type == 'layout'
variables << {
variable: '{{ content_for_layout }}',
description: 'Required. Renders the email body inside the layout.'
}
end
variables + [
{
variable: "{{ global_config['BRAND_NAME'] }}",
description: 'Current brand name from Custom Branding.'
},
{
variable: "{{ global_config['BRAND_URL'] }}",
description: 'Current brand URL from Custom Branding.'
}
].tap do |template_variables|
next if @account.blank?
template_variables << {
variable: '{{ account.name }}',
description: 'Current account name for account-specific previews.'
}
end
end
def conflict_error
"Cannot save template. '#{template_name}' is already used by another email template for this account, type, and language."
end
def conflicting_template_exists?
return false if @email_layout.present?
EmailTemplate.exists?(name: template_name, template_type: template_type, locale: template_locale, account: @account)
end
def edit_email_layout_path
query = { template_type: template_type, template_name: template_name, locale: template_locale }
return edit_super_admin_account_email_layout_path(@account, query) if @account.present?
edit_super_admin_email_layout_path(query)
end
def email_layout_overview_path
return edit_super_admin_account_email_layout_path(@account) if @account.present?
edit_super_admin_email_layout_path
end
def editor_requested?
params[:new].present? || params[:template_type].present? || params[:template_name].present? || params[:locale].present?
end
def reset_notice
return 'Account email template reset to inherited template' if @account.present?
'Email template reset to default'
end
def ensure_custom_branding_access!
return if ChatwootHub.pricing_plan != 'community'
invalid_action_perfomed
end
def selected_template_type
selected_type = params.dig(:email_layout, :template_type).presence || params[:template_type].presence || 'layout'
EmailTemplate.template_types.key?(selected_type) ? selected_type : 'layout'
end
def selected_template_name
selected_name = params.dig(:email_layout, :template_name).presence || params[:template_name].presence
selected_name = default_template_name if selected_name.blank?
available_template_names.include?(selected_name) ? selected_name : default_template_name
end
def selected_template_locale
selected_locale = params.dig(:email_layout, :locale).presence || params[:locale].presence || 'en'
EmailTemplate.locales.key?(selected_locale) ? selected_locale : 'en'
end
def default_template_name
return 'base' if template_type == 'layout'
content_template_options.first[:name]
end
def template_options
return [{ name: 'base', label: 'Base layout' }] if template_type == 'layout'
content_template_options
end
def available_template_names
template_options.pluck(:name)
end
def content_template_options
@content_template_options ||= Rails.root.glob('app/views/mailers/**/*.liquid').map do |path|
name = path.relative_path_from(Rails.root.join('app/views/mailers')).to_s.delete_suffix('.liquid')
{ name: name, label: name.humanize }
end.sort_by { |template| template[:label] }
end
def content_template_path
Rails.root.join('app/views/mailers', "#{template_name}.liquid")
end
def locale_options
EmailTemplate.locales.keys.map { |locale| [locale.upcase, locale] }
end
def template_status
return 'Account override configured' if @account.present? && @email_layout.present?
return 'Global override configured' if @account.blank? && @email_layout.present?
return 'Inherits global template' if @account.present? && global_template_body.present?
'Inherits default template'
end
end
@@ -68,30 +68,6 @@ module Enterprise::Account
saml_settings&.saml_enabled? || false
end
def billing_currency
# Feature off => everyone is billed in USD (legacy behaviour).
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.enabled?
stored = custom_attributes&.dig('billing_currency')
return Enterprise::Billing::Currencies.normalize(stored) if Enterprise::Billing::Currencies.supported?(stored)
# Existing Stripe customers stay on USD (webhook backfills the real currency);
# only brand-new accounts infer from locale, so existing pt_BR users aren't charged BRL.
return Enterprise::Billing::Currencies::DEFAULT if custom_attributes&.dig('stripe_customer_id').present?
Enterprise::Billing::Currencies.for_locale(locale)
end
# New accounts whose locale maps to a non-USD currency get to pick USD or that
# currency before the Stripe customer is created; everyone else proceeds in USD.
def billing_currency_selection_required?
return false unless Enterprise::Billing::Currencies.enabled?
return false if custom_attributes&.dig('stripe_customer_id').present?
return false if Enterprise::Billing::Currencies.supported?(custom_attributes&.dig('billing_currency'))
Enterprise::Billing::Currencies.for_locale(locale) != Enterprise::Billing::Currencies::DEFAULT
end
private
def sync_assignment_features
@@ -1,6 +1,4 @@
class Enterprise::Billing::CreateStripeCustomerService
include BillingHelper
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
@@ -23,22 +21,13 @@ class Enterprise::Billing::CreateStripeCustomerService
def prepare_customer_id
customer_id = account.custom_attributes['stripe_customer_id']
customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
if customer_id.blank?
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
customer_id = customer.id
end
customer_id
end
# Only currencies that need a country override (e.g. BRL/PIX) set address/locale; usd keeps Stripe defaults.
def customer_params
params = { name: account.name, email: billing_email }
country = Enterprise::Billing::Currencies.country_for(account.billing_currency)
return params if country.blank?
params.merge(
address: { country: country },
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
)
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
@@ -48,11 +37,13 @@ class Enterprise::Billing::CreateStripeCustomerService
end
def default_plan
@default_plan ||= Enterprise::Billing::PlanConfiguration.default_plan
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
@default_plan ||= installation_config.value.first
end
def price_id
Enterprise::Billing::PlanConfiguration.price_id_for(default_plan, account.billing_currency)
price_ids = default_plan['price_ids']
price_ids.first
end
def active_subscription
@@ -69,7 +60,7 @@ class Enterprise::Billing::CreateStripeCustomerService
end
def default_plan_subscription?(subscription)
Enterprise::Billing::PlanConfiguration.plan_contains_product_id?(default_plan, subscription['plan']['product'])
default_plan['price_ids'].include?(subscription['plan']['id'])
end
def build_custom_attributes(customer_id, subscription)
@@ -80,14 +71,14 @@ class Enterprise::Billing::CreateStripeCustomerService
'plan_name' => default_plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => subscription_ends_on(subscription),
'billing_currency' => billing_currency_for(subscription)
'subscription_ends_on' => subscription_ends_on(subscription)
)
end
# Persist the currency Stripe actually billed, read straight from the price; the
# requested currency may lack a configured price and fall back to usd.
def billing_currency_for(subscription)
Enterprise::Billing::Currencies.to_supported(subscription['plan']['currency'])
def subscription_ends_on(subscription)
period_end = subscription['current_period_end']
return if period_end.blank?
Time.zone.at(period_end)
end
end
@@ -1,55 +0,0 @@
# Supported billing currencies and their Stripe/locale mappings.
module Enterprise::Billing::Currencies
DEFAULT = 'usd'.freeze
SUPPORTED = %w[usd brl].freeze
FEATURE_CONFIG = 'ENABLE_MULTI_CURRENCY_BILLING'.freeze
# Account locale label (e.g. 'pt_BR') => default currency; unlisted falls back to DEFAULT.
LOCALE_DEFAULTS = {
'pt_BR' => 'brl'
}.freeze
# Billing country override per currency; absent currencies (e.g. usd) keep Stripe's default.
COUNTRY_BY_CURRENCY = {
'brl' => 'BR'
}.freeze
# Preferred Stripe/checkout locale per currency; absent currencies keep Stripe's default.
PREFERRED_LOCALE_BY_CURRENCY = {
'brl' => 'pt-BR'
}.freeze
module_function
# Master switch for the whole multi-currency feature; off => everyone is billed in USD.
def enabled?
GlobalConfigService.load(FEATURE_CONFIG, 'false').to_s != 'false'
end
def normalize(code)
code.to_s.strip.downcase.presence
end
def supported?(code)
SUPPORTED.include?(normalize(code))
end
# Map arbitrary input to a supported code, else DEFAULT.
def to_supported(code)
supported?(code) ? normalize(code) : DEFAULT
end
def for_locale(locale)
LOCALE_DEFAULTS.fetch(locale.to_s, DEFAULT)
end
def country_for(code)
COUNTRY_BY_CURRENCY[to_supported(code)]
end
def preferred_locale_for(code)
PREFERRED_LOCALE_BY_CURRENCY[to_supported(code)]
end
end
@@ -1,6 +1,4 @@
class Enterprise::Billing::HandleStripeEventService
include BillingHelper
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
@@ -67,19 +65,11 @@ class Enterprise::Billing::HandleStripeEventService
'plan_name' => plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => subscription_ends_on(subscription),
'billing_currency' => billing_currency_for(subscription, plan)
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
)
)
end
# Paid subscriptions define the currency; the free/default plan keeps the stored preference.
def billing_currency_for(subscription, plan)
return account.billing_currency if plan['name'] == Enterprise::Billing::PlanConfiguration.default_plan&.dig('name')
Enterprise::Billing::Currencies.to_supported(subscription['plan']['currency'])
end
def track_marketing_plan_activation(previous_plan_name, current_plan_name)
subscription_plan = subscription['plan']
@@ -171,8 +161,8 @@ class Enterprise::Billing::HandleStripeEventService
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
def find_plan(product_id)
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
def find_plan(plan_id)
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def previous_plan_name
@@ -181,4 +171,8 @@ class Enterprise::Billing::HandleStripeEventService
find_plan(stripe_plan['product'])&.dig('name')
end
def cloud_plans
@cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
end
end
@@ -1,46 +0,0 @@
# Resolves Stripe price ids from CHATWOOT_CLOUD_PLANS per currency.
# A plan's `price_ids` may be a currency-keyed Hash, or a legacy Array (treated as usd).
module Enterprise::Billing::PlanConfiguration
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
module_function
def plans
InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
end
def default_plan
plans.first
end
# Handles both shapes during migration; once all configs are currency-keyed Hashes, drop the Array branch.
def price_ids_by_currency(plan)
raw = plan && plan['price_ids']
case raw
when Hash then raw.transform_keys { |key| Enterprise::Billing::Currencies.normalize(key) }
when Array then { Enterprise::Billing::Currencies::DEFAULT => raw }
else {}
end
end
# Price id for `plan` in `currency`, falling back to usd then any configured price.
# The multi-step fallback is migration-era safety; once configs settle on one format we can simplify this.
def price_id_for(plan, currency)
by_currency = price_ids_by_currency(plan)
code = Enterprise::Billing::Currencies.to_supported(currency)
(by_currency[code].presence ||
by_currency[Enterprise::Billing::Currencies::DEFAULT].presence ||
by_currency.values.flatten.compact).first
end
# Match by product id, not price id: production has prices that aren't enumerated
# in our config but share a product, so product matching still resolves the plan.
def plan_contains_product_id?(plan, product_id)
Array(plan && plan['product_id']).include?(product_id)
end
def find_plan_by_product_id(product_id)
plans.find { |plan| plan_contains_product_id?(plan, product_id) }
end
end
@@ -3,15 +3,15 @@ class Enterprise::Billing::TopupCheckoutService
class Error < StandardError; end
TOPUP_OPTIONS_CONFIG = 'CAPTAIN_TOPUP_OPTIONS'.freeze
TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12_000, amount: 200.0, currency: 'usd' }
].freeze
pattr_initialize [:account!]
# Topup packages for the account's billing currency (used by the controller).
def available_options
topup_options
end
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
charge_customer(topup_option, credits)
@@ -100,20 +100,6 @@ class Enterprise::Billing::TopupCheckoutService
end
def find_topup_option(credits)
topup_options.find { |opt| opt[:credits] == credits.to_i }
end
def topup_options
# Label rows with the currency they were configured under, so a DEFAULT fallback can't relabel USD amounts and undercharge.
options = configured_options
currency = options[account.billing_currency].present? ? account.billing_currency : Enterprise::Billing::Currencies::DEFAULT
rows = options[currency].presence || []
rows.map { |opt| { credits: opt['credits'].to_i, amount: opt['amount'].to_f, currency: currency } }
end
def configured_options
config = InstallationConfig.find_by(name: TOPUP_OPTIONS_CONFIG)&.value
config = JSON.parse(config) if config.is_a?(String)
config || {}
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
end
end
@@ -30,5 +30,22 @@
</span>
</div>
<% end %>
<% if ChatwootHub.pricing_plan != 'community' %>
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
<div class="flex items-center gap-2">
<span class="bg-n-amber-3 text-n-amber-12 rounded-full p-1 inline-flex right-4 top-5">
<svg width="12" height="12"><use xlink:href="#icon-lock-line" /></svg>
</span>
<span class="text-sm text-n-slate-12">Account Email Templates</span>
</div>
<%= link_to edit_super_admin_account_email_layout_path(field.resource),
aria: { label: 'Customize account email templates' },
title: 'Customize account email templates',
class: 'inline-flex rounded-full bg-slate-50 p-1 text-slate-800 hover:bg-n-slate-4 hover:text-n-slate-12 focus:outline-none' do %>
<svg width="12" height="12"><use xlink:href="#icon-settings-2-line" /></svg>
<% end %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1,15 @@
<% if ChatwootHub.pricing_plan != 'community' %>
<div class="absolute right-4 top-5 w-96 rounded-md border border-slate-100 p-4">
<div class="flex items-start justify-between gap-5">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-n-slate-12">Email templates</span>
<p class="m-0 text-xs italic leading-5 text-slate-400">
Customize the notification email layout for this brand.
</p>
</div>
<%= link_to 'Manage',
edit_super_admin_email_layout_path,
class: 'flex h-8 shrink-0 items-center rounded-md bg-transparent px-3 text-sm font-medium text-n-slate-11 outline outline-1 outline-n-container hover:bg-slate-50 hover:text-n-slate-12 focus:outline-none' %>
</div>
</div>
<% end %>
@@ -0,0 +1,241 @@
<% content_for(:title) do %>
Configure Settings - <%= @account.present? ? "#{@account.name} Email Templates" : 'Email Templates' %>
<% end %>
<% overview_path = @account.present? ? edit_super_admin_account_email_layout_path(@account) : edit_super_admin_email_layout_path %>
<% new_template_path = @account.present? ? edit_super_admin_account_email_layout_path(@account, new: 1, template_type: 'layout', template_name: 'base', locale: 'en') : edit_super_admin_email_layout_path(new: 1, template_type: 'layout', template_name: 'base', locale: 'en') %>
<header class="main-content__header" role="banner">
<div class="flex items-center justify-between w-full">
<h1 class="main-content__page-title m-0 mr-6" id="page-title">
<%= content_for(:title) %>
</h1>
<% unless @show_template_editor %>
<div class="whitespace-nowrap ml-4">
<%= link_to 'Create new template', new_template_path, class: 'button' %>
</div>
<% end %>
</div>
</header>
<% unless @show_template_editor %>
<section class="main-content__body main-content__body--flush">
<table aria-labelledby="page-title">
<thead>
<tr>
<th class="cell-label cell-label--string" scope="col">Type</th>
<th class="cell-label cell-label--string" scope="col">Template</th>
<th class="cell-label cell-label--string" scope="col">Language</th>
<th class="cell-label cell-label--string" scope="col">Updated</th>
</tr>
</thead>
<tbody>
<% @custom_templates.each do |template| %>
<% template_path = @account.present? ? edit_super_admin_account_email_layout_path(@account, template_type: template.template_type, template_name: template.name, locale: template.locale) : edit_super_admin_email_layout_path(template_type: template.template_type, template_name: template.name, locale: template.locale) %>
<tr class="js-table-row" tabindex="0" role="link" data-url="<%= template_path %>">
<td class="cell-data cell-data--string">
<%= link_to template.template_type.titleize, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to template.name.humanize, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to template.locale.upcase, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to "#{time_ago_in_words(template.updated_at)} ago", template_path, class: 'action-show' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</section>
<% else %>
<section class="main-content__body">
<div class="pr-16">
<%= form_tag overview_path, method: :get, id: 'email-template-selector-form', autocomplete: 'off' do %>
<%= hidden_field_tag :new, 1 %>
<fieldset>
<legend>Template details</legend>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :template_type, 'Type' %>
</div>
<div class="field-unit__field">
<%= select_tag :template_type,
options_for_select(EmailTemplate.template_types.keys.map { |type| [type.titleize, type] }, @template_type),
data: { current_value: @template_type } %>
</div>
</div>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :template_name, 'Template' %>
</div>
<div class="field-unit__field">
<%= select_tag :template_name,
options_for_select(@template_options.map { |template| [template[:label], template[:name]] }, @template_name),
data: { current_value: @template_name } %>
<p class="pt-2 text-xs italic text-slate-400"><%= @template_status %></p>
</div>
</div>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :locale, 'Language' %>
</div>
<div class="field-unit__field">
<%= select_tag :locale,
options_for_select(@locale_options, @template_locale),
data: { current_value: @template_locale } %>
</div>
</div>
</fieldset>
<div class="form-actions">
<%= submit_tag 'Load' %>
<%= link_to 'All templates', overview_path, class: 'button' %>
</div>
<% end %>
<%= form_with scope: :email_layout,
url: @account.present? ? super_admin_account_email_layout_path(@account) : super_admin_email_layout_path,
method: :patch do |form| %>
<%= form.hidden_field :template_type, value: @template_type %>
<%= form.hidden_field :template_name, value: @template_name %>
<%= form.hidden_field :locale, value: @template_locale %>
<fieldset>
<legend>Template body</legend>
<div class="field-unit field-unit--text">
<div class="field-unit__label">
<%= form.label :body, 'HTML / Liquid' %>
</div>
<div class="field-unit__field">
<%= form.text_area :body,
value: @layout_body,
rows: 24,
wrap: 'off',
data: { email_template_editor: true },
class: 'mt-2 w-full border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll' %>
<% if @template_type == 'layout' %>
<p class="pt-2 text-xs italic text-slate-400">Keep {{ content_for_layout }} in the layout.</p>
<% end %>
</div>
</div>
</fieldset>
<div class="form-actions">
<%= form.submit @email_layout.present? ? 'Save template' : 'Create template override' %>
<% if @email_layout.present? %>
<%= link_to t('administrate.actions.destroy'),
@account.present? ? super_admin_account_email_layout_path(@account, template_type: @template_type, template_name: @template_name, locale: @template_locale) : super_admin_email_layout_path(template_type: @template_type, template_name: @template_name, locale: @template_locale),
class: 'button button--danger',
method: :delete,
data: { confirm: t('administrate.actions.confirm') } %>
<% end %>
</div>
<% end %>
<fieldset>
<legend>Preview</legend>
<div class="attribute-label">Email layout preview</div>
<div class="attribute-data attribute-data--string">
<iframe
title="Email layout preview"
sandbox
height="420"
srcdoc="<%= @preview_html %>"
data-email-template-preview="true"
class="w-full rounded-md border border-slate-100 bg-white"
></iframe>
<p class="pt-2 text-xs italic text-slate-400">Refreshes as you type.</p>
</div>
<div class="attribute-label">Variables</div>
<div class="attribute-data attribute-data--string">
<% @supported_variables.each do |supported_variable| %>
<p class="m-0 mb-2 text-xs text-slate-500">
<code class="rounded bg-slate-25 px-2 py-1 font-mono text-xs text-slate-700"><%= supported_variable[:variable] %></code>
<%= supported_variable[:description] %>
</p>
<% end %>
</div>
</fieldset>
<script type="application/json" id="email-preview-config"><%= raw json_escape(@preview_config.to_json) %></script>
</div>
</section>
<% end %>
<% if @show_template_editor %>
<% content_for :javascript do %>
<script>
const initializeEmailTemplateEditor = () => {
const selectorForm = document.getElementById('email-template-selector-form') || document.querySelector('form[method="get"]');
if (selectorForm) {
let isSubmitting = false;
const submitSelectorForm = select => {
if (isSubmitting) return;
const templateSelect = selectorForm.querySelector('select[name="template_name"]');
if (select.name === 'template_type' && templateSelect) {
templateSelect.disabled = true;
}
isSubmitting = true;
selectorForm.requestSubmit();
};
selectorForm.querySelectorAll('select').forEach(select => {
if (select.dataset.currentValue) {
if (select.selectize) {
select.selectize.setValue(select.dataset.currentValue, true);
} else {
select.value = select.dataset.currentValue;
}
}
select.addEventListener('change', () => submitSelectorForm(select));
select.selectize?.on('change', () => submitSelectorForm(select));
});
}
const editor = document.querySelector('[data-email-template-editor]');
const preview = document.querySelector('[data-email-template-preview]');
const configNode = document.getElementById('email-preview-config');
if (!editor || !preview || !configNode) return;
const config = JSON.parse(configNode.textContent);
const contentSlotPattern = /\{\{\s*content_for_layout\s*\}\}/g;
const replacements = [
[/\{\{\s*global_config\[['"]BRAND_NAME['"]\]\s*\}\}/g, config.brandName],
[/\{\{\s*global_config\[['"]BRAND_URL['"]\]\s*\}\}/g, config.brandUrl],
[/\{\{\s*account\.name\s*\}\}/g, config.accountName],
];
const renderTemplateVariables = value => replacements.reduce((body, [pattern, replacement]) => body.replace(pattern, replacement || ''), value || '');
const renderPreview = () => {
const renderedBody = renderTemplateVariables(editor.value);
preview.srcdoc = config.templateType === 'layout'
? renderedBody.replace(contentSlotPattern, config.previewContent)
: renderTemplateVariables(config.defaultLayout).replace(contentSlotPattern, renderedBody);
};
editor.addEventListener('input', renderPreview);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeEmailTemplateEditor);
} else {
initializeEmailTemplateEditor();
}
</script>
<% end %>
<% end %>
@@ -107,24 +107,6 @@ describe ContactIdentifyAction do
end
end
context 'when a request without an identifier matches an already identified contact' do
it 'does not merge into the identified contact via a matching email' do
victim = create(:contact, account: account, identifier: 'victim_id', email: 'victim@test.com', name: 'Victim')
params = { email: 'victim@test.com', name: 'Attacker' }
result = described_class.new(contact: contact, params: params).perform
expect(result.id).not_to eq victim.id
expect(victim.reload.name).to eq 'Victim'
end
it 'does not merge into the identified contact via a matching phone_number' do
victim = create(:contact, account: account, identifier: 'victim_id', phone_number: '+919999888877', name: 'Victim')
params = { phone_number: '+919999888877', name: 'Attacker' }
result = described_class.new(contact: contact, params: params).perform
expect(result.id).not_to eq victim.id
expect(victim.reload.name).to eq 'Victim'
end
end
context 'when contacts with blank identifiers exist and identify action is called with blank identifier' do
it 'updates the attributes of contact passed in to identify action' do
create(:contact, account: account, identifier: '')
@@ -2,6 +2,7 @@ require 'rails_helper'
RSpec.describe 'Super Admin Application Config API', type: :request do
let(:super_admin) { create(:super_admin) }
let(:account) { create(:account) }
describe 'GET /super_admin/app_config' do
context 'when it is an unauthenticated super admin' do
@@ -12,7 +13,13 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
end
context 'when it is an authenticated super admin' do
let!(:config) { create(:installation_config, { name: 'FB_APP_ID', value: 'TESTVALUE' }) }
let!(:config) do
InstallationConfig.where(name: 'FB_APP_ID').first_or_initialize.tap do |installation_config|
installation_config.value = 'TESTVALUE'
installation_config.locked = false
installation_config.save!
end
end
it 'shows the app_config page' do
sign_in(super_admin, scope: :super_admin)
@@ -20,6 +27,32 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(config.value)
end
it 'shows email template customization under custom branding' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/app_config?config=custom_branding'
expect(response).to have_http_status(:success)
expect(response.body).to include('Email templates')
expect(response.body).to include('Customize the notification email layout for this brand')
expect(response.body).to include('Manage')
expect(response.body).not_to include('Shape the brand customers see across Chatwoot and email')
expect(response.body).to include('/super_admin/email_layout/edit')
end
it 'shows email template customization on account details' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}"
expect(response).to have_http_status(:success)
expect(response.body).to include('Account Email Templates')
expect(response.body).to include('Customize account email templates')
expect(response.body).to include(edit_super_admin_account_email_layout_path(account))
end
end
end
@@ -0,0 +1,230 @@
require 'rails_helper'
RSpec.describe 'Super Admin Email Layouts API', type: :request do
let(:super_admin) { create(:super_admin) }
let(:account) { create(:account, name: 'Acme Inc') }
describe 'GET /super_admin/email_layout/edit' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
it 'shows the email layout page' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:success)
expect(response.body).to include('Configure Settings - Email Templates')
expect(response.body).to include('Create new template')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Type</th>')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Template</th>')
end
it 'shows the editor when creating a new template override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit', params: { new: 1, template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Template details')
expect(response.body).to include('email-template-selector-form')
expect(response.body).to include('Type')
expect(response.body).to include('Language')
expect(response.body).to include('Inherits default template')
expect(response.body).to include('Email layout preview')
expect(response.body).to include('Variables')
expect(response.body).to include('{{ content_for_layout }}')
expect(response.body).to include('global_config[&#39;BRAND_NAME&#39;]')
expect(response.body).to include('global_config[&#39;BRAND_URL&#39;]')
end
it 'shows the account email layout page' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit"
expect(response).to have_http_status(:success)
expect(response.body).to include('Configure Settings - Acme Inc Email Templates')
expect(response.body).to include('Create new template')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Type</th>')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Template</th>')
end
it 'lists configured account email templates' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(
:email_template,
name: 'agent_notifications/conversation_notifications_mailer/conversation_assignment',
body: 'Assigned',
template_type: :content,
locale: :fr,
account: account
)
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit"
expect(response).to have_http_status(:success)
expect(response.body).to include('Create new template')
expect(response.body).to include('Content')
expect(response.body).to include('FR')
expect(response.body).to include('Agent notifications/conversation notifications mailer/conversation assignment')
end
it 'shows the account email layout editor when creating a new template override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit",
params: { new: 1, template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Template details')
expect(response.body).to include('Acme Inc')
expect(response.body).to include('{{ account.name }}')
end
it 'shows delete action for configured account email template overrides' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, locale: :en, account: account)
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit", params: { template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Destroy')
expect(response.body).to include('button button--danger')
expect(response.body).to include('Are you sure?')
end
it 'supports selecting content templates by locale' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit",
params: {
template_type: 'content',
template_name: 'agent_notifications/conversation_notifications_mailer/conversation_assignment',
locale: 'fr'
}
expect(response).to have_http_status(:success)
expect(response.body).to include('Content')
expect(response.body).to include('conversation assignment')
expect(response.body).to include('FR')
end
it 'blocks access on community plan' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('community')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:redirect)
expect(response).to redirect_to(root_path)
end
end
end
describe 'PATCH /super_admin/email_layout' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
patch '/super_admin/email_layout', params: { email_layout: { body: '{{ content_for_layout }}' } }
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
before do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
end
it 'creates a layout override' do
body = '<html><body>{{ content_for_layout }}</body></html>'
patch '/super_admin/email_layout', params: { email_layout: { body: body } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_email_layout_path(template_type: 'layout', template_name: 'base', locale: 'en'))
template = EmailTemplate.find_by(name: 'base', account: nil)
expect(template).to be_present
expect(template.template_type).to eq('layout')
expect(template.body).to eq(body)
end
it 'creates an account layout override' do
body = '<html><body>{{ content_for_layout }} {{ account.name }}</body></html>'
patch "/super_admin/accounts/#{account.id}/email_layout", params: { email_layout: { body: body } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_account_email_layout_path(account, template_type: 'layout', template_name: 'base',
locale: 'en'))
template = EmailTemplate.find_by(name: 'base', account: account)
expect(template).to be_present
expect(template.template_type).to eq('layout')
expect(template.body).to eq(body)
end
it 'returns validation error when content_for_layout is missing' do
patch '/super_admin/email_layout', params: { email_layout: { body: '<html><body>No slot</body></html>' } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Layout must include')
end
it 'returns validation error for invalid liquid syntax' do
patch '/super_admin/email_layout', params: { email_layout: { body: '{{ content_for_layout }} {{ invalid ' } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Liquid syntax error')
end
end
end
describe 'DELETE /super_admin/email_layout' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
delete '/super_admin/email_layout'
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
it 'removes custom layout override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, account: nil)
sign_in(super_admin, scope: :super_admin)
delete '/super_admin/email_layout'
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_email_layout_path)
expect(EmailTemplate.find_by(name: 'base', account: nil)).to be_nil
end
it 'removes account layout override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, account: account)
sign_in(super_admin, scope: :super_admin)
delete "/super_admin/accounts/#{account.id}/email_layout"
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_account_email_layout_path(account))
expect(EmailTemplate.find_by(name: 'base', account: account)).to be_nil
end
end
end
end
@@ -256,14 +256,6 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
'usd' => [
{ 'credits' => 1000, 'amount' => 20.0 },
{ 'credits' => 2500, 'amount' => 50.0 },
{ 'credits' => 6000, 'amount' => 100.0 },
{ 'credits' => 12_000, 'amount' => 200.0 }
]
})
end
it 'returns unauthorized for unauthenticated user' do
@@ -82,8 +82,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
subscribed_quantity: 2,
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on,
billing_currency: 'usd'
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
@@ -96,9 +95,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with(
{ name: account.name, email: admin1.email }
)
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 }] })
@@ -111,27 +108,10 @@ describe Enterprise::Billing::CreateStripeCustomerService do
subscribed_quantity: 2,
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on,
billing_currency: 'usd'
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
it 'sets the billing country override when the account currency requires it' do
with_modified_env ENABLE_MULTI_CURRENCY_BILLING: 'true' do
account.update!(custom_attributes: { billing_currency: 'brl' })
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(created_subscription)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with(
{ name: account.name, email: admin1.email, address: { country: 'BR' }, preferred_locales: ['pt-BR'] }
)
end
end
end
describe 'when checking for existing subscriptions' do
@@ -1,36 +0,0 @@
require 'rails_helper'
describe Enterprise::Billing::Currencies do
describe 'Brazilian Real (brl)' do
it 'is a supported currency' do
expect(described_class.supported?('brl')).to be(true)
end
it 'recognizes brl regardless of casing or surrounding whitespace' do
expect(described_class.supported?(' BRL ')).to be(true)
expect(described_class.normalize(' BRL ')).to eq('brl')
end
it 'keeps brl when coercing to a supported code' do
expect(described_class.to_supported('BRL')).to eq('brl')
end
it 'defaults the pt_BR account locale to brl' do
expect(described_class.for_locale('pt_BR')).to eq('brl')
end
it 'maps brl to Brazil and the pt-BR checkout locale' do
expect(described_class.country_for('brl')).to eq('BR')
expect(described_class.preferred_locale_for('brl')).to eq('pt-BR')
end
it 'falls back to the usd default for unsupported input' do
expect(described_class.to_supported('eur')).to eq('usd')
end
it 'does not set a country override for usd customers' do
expect(described_class.country_for('usd')).to be_nil
expect(described_class.preferred_locale_for('usd')).to be_nil
end
end
end
@@ -15,15 +15,6 @@ describe Enterprise::Billing::TopupCheckoutService do
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
])
create(:installation_config, name: 'CAPTAIN_TOPUP_OPTIONS', value: {
'usd' => [
{ 'credits' => 1000, 'amount' => 20.0 },
{ 'credits' => 2500, 'amount' => 50.0 },
{ 'credits' => 6000, 'amount' => 100.0 },
{ 'credits' => 12_000, 'amount' => 200.0 }
]
})
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 500 }