Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7edbe3a2b | ||
|
|
bac921133a |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
|
||||
+43
-91
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
%>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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['BRAND_NAME']')
|
||||
expect(response.body).to include('global_config['BRAND_URL']')
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user