Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da0fc87583 | ||
|
|
8a760da6eb | ||
|
|
e1d512cddd | ||
|
|
bdb3bec11a | ||
|
|
b733ddb8ff | ||
|
|
3c5c9574a3 | ||
|
|
de1469d2ca | ||
|
|
49aec5517b | ||
|
|
c6210a4476 | ||
|
|
6d8893c20c | ||
|
|
860c9c966c | ||
|
|
4e90084d9e | ||
|
|
552d598060 | ||
|
|
b2dbdb908a | ||
|
|
03feaad417 | ||
|
|
ff35dcb7ff | ||
|
|
fca9d9009d | ||
|
|
06e15ff081 | ||
|
|
56d9b232d1 | ||
|
|
15f920c9af | ||
|
|
d191603620 | ||
|
|
20911a01dc | ||
|
|
6f6769b66f | ||
|
|
9ed49bfc80 | ||
|
|
3680da7e48 | ||
|
|
7fd3338112 | ||
|
|
6ffc1c5f75 | ||
|
|
37470d7e57 | ||
|
|
6d9242cfb7 | ||
|
|
601625aeb3 | ||
|
|
574efb4d9e | ||
|
|
67dc0d7090 | ||
|
|
54662b17f7 | ||
|
|
879606fd19 | ||
|
|
462100cba1 | ||
|
|
461df26d90 | ||
|
|
aab8c848b6 | ||
|
|
29e1eaed19 | ||
|
|
3bbaacac40 | ||
|
|
ea2c08669f | ||
|
|
eeca70e841 | ||
|
|
1db8b7e722 | ||
|
|
dcfb0d9857 | ||
|
|
d6c75586df | ||
|
|
13402a3a9a | ||
|
|
ba38d54321 | ||
|
|
f1bc3208e9 | ||
|
|
bbd575d8fa | ||
|
|
4bf6a15c06 | ||
|
|
8995203473 | ||
|
|
4e33490676 | ||
|
|
0768ce70ab | ||
|
|
b38618b5d3 |
@@ -24,6 +24,7 @@ class DashboardController < ActionController::Base
|
||||
DISABLE_USER_PROFILE_UPDATE
|
||||
DEPLOYMENT_ENV
|
||||
INSTALLATION_PRICING_PLAN
|
||||
MULTIPLE_CURRENCY_SUPPORTED
|
||||
].freeze
|
||||
|
||||
before_action :set_application_pack
|
||||
|
||||
@@ -22,4 +22,16 @@ 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
|
||||
|
||||
@@ -27,6 +27,15 @@ 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`);
|
||||
}
|
||||
|
||||
switchCurrency(currency) {
|
||||
return axios.post(`${this.url}switch_currency`, { currency });
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Single source of truth for billing currencies on the frontend.
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -450,7 +450,14 @@
|
||||
"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"
|
||||
"RENEWS_ON": "Renews on",
|
||||
"CURRENCY": "Currency"
|
||||
},
|
||||
"CURRENCY": {
|
||||
"OPTIONS": {
|
||||
"USD": "US Dollar (USD)",
|
||||
"BRL": "Brazilian Real (BRL)"
|
||||
}
|
||||
},
|
||||
"VIEW_PRICING": "View Pricing",
|
||||
"MANAGE_SUBSCRIPTION": {
|
||||
@@ -458,6 +465,23 @@
|
||||
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
|
||||
"BUTTON_TXT": "Go to the billing portal"
|
||||
},
|
||||
"CURRENCY": {
|
||||
"TITLE": "Billing currency",
|
||||
"DESCRIPTION": "Choose the currency used for your subscription and credit purchases.",
|
||||
"OPTIONS": {
|
||||
"USD": "US Dollar (USD)",
|
||||
"BRL": "Brazilian Real (BRL)"
|
||||
},
|
||||
"SUCCESS": "Billing currency updated successfully.",
|
||||
"ERROR": "Failed to update billing currency. Please try again.",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Switch billing currency?",
|
||||
"DESCRIPTION": "You are about to switch your billing currency to {currency}.",
|
||||
"WARNING": "Your current subscription will be cancelled and a new one will be started in the selected currency. Pricing and any remaining credits may differ. This cannot be undone automatically.",
|
||||
"CONFIRM_BUTTON": "Switch currency",
|
||||
"CANCEL_BUTTON": "Cancel"
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"TITLE": "Captain",
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
@@ -486,6 +510,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
import {
|
||||
SUPPORTED_BILLING_CURRENCIES,
|
||||
DEFAULT_BILLING_CURRENCY,
|
||||
getCurrencyConfig,
|
||||
} from 'dashboard/constants/billing';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import SwitchCurrencyDialog from './components/SwitchCurrencyDialog.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
@@ -28,6 +38,7 @@ const {
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
@@ -61,6 +72,13 @@ 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);
|
||||
@@ -118,6 +136,55 @@ const handleBillingPageLogic = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSwitchingCurrency = computed(() => uiFlags.value.isSwitchingCurrency);
|
||||
|
||||
// Currency switching is shown only when multi-currency billing is enabled for the installation.
|
||||
const showCurrencyToggle = computed(
|
||||
() => globalConfig.value.multipleCurrencySupported
|
||||
);
|
||||
|
||||
const currentBillingCurrency = computed(() =>
|
||||
(
|
||||
customAttributes.value.billing_currency || DEFAULT_BILLING_CURRENCY
|
||||
).toLowerCase()
|
||||
);
|
||||
|
||||
const currencyTabs = computed(() =>
|
||||
SUPPORTED_BILLING_CURRENCIES.map(code => ({
|
||||
label: t(getCurrencyConfig(code).i18nLabelKey),
|
||||
value: code,
|
||||
}))
|
||||
);
|
||||
|
||||
const activeCurrencyIndex = computed(() => {
|
||||
const index = SUPPORTED_BILLING_CURRENCIES.indexOf(
|
||||
currentBillingCurrency.value
|
||||
);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const pendingCurrency = ref(null);
|
||||
const switchCurrencyDialogRef = ref(null);
|
||||
|
||||
const onSelectCurrency = tab => {
|
||||
if (!tab?.value || tab.value === currentBillingCurrency.value) return;
|
||||
if (isSwitchingCurrency.value) return;
|
||||
pendingCurrency.value = tab.value;
|
||||
switchCurrencyDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const onConfirmSwitchCurrency = async () => {
|
||||
try {
|
||||
await store.dispatch('accounts/switchBillingCurrency', {
|
||||
currency: pendingCurrency.value,
|
||||
});
|
||||
switchCurrencyDialogRef.value?.close();
|
||||
useAlert(t('BILLING_SETTINGS.CURRENCY.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('BILLING_SETTINGS.CURRENCY.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
@@ -188,8 +255,30 @@ 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
|
||||
v-if="showCurrencyToggle"
|
||||
:title="$t('BILLING_SETTINGS.CURRENCY.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CURRENCY.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<div
|
||||
:class="{ 'pointer-events-none opacity-60': isSwitchingCurrency }"
|
||||
>
|
||||
<TabBar
|
||||
:tabs="currencyTabs"
|
||||
:initial-active-tab="activeCurrencyIndex"
|
||||
@tab-changed="onSelectCurrency"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="captainEnabled"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
@@ -263,6 +352,12 @@ onMounted(handleBillingPageLogic);
|
||||
ref="purchaseCreditsModalRef"
|
||||
@success="handleTopupSuccess"
|
||||
/>
|
||||
<SwitchCurrencyDialog
|
||||
ref="switchCurrencyDialogRef"
|
||||
:target-currency="pendingCurrency"
|
||||
:is-loading="isSwitchingCurrency"
|
||||
@confirm="onConfirmSwitchCurrency"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+3
-5
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { formatCurrencyAmount } from 'dashboard/constants/billing';
|
||||
|
||||
defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
@@ -33,11 +35,7 @@ const formatCredits = credits => {
|
||||
};
|
||||
|
||||
const formatAmount = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
return formatCurrencyAmount(amount, currency, { minimumFractionDigits: 0 });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
+91
-43
@@ -4,20 +4,18 @@ 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(['close', 'success']);
|
||||
const emit = defineEmits(['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';
|
||||
@@ -27,16 +25,20 @@ 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 TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||
return topupOptions.value.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: selectedOption.value.currency.toUpperCase(),
|
||||
}).format(selectedOption.value.amount);
|
||||
const { amount, currency } = selectedOption.value;
|
||||
return formatCurrencyAmount(amount, currency || optionsCurrency.value);
|
||||
});
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
@@ -64,24 +66,44 @@ const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const popularOption = TOPUP_OPTIONS.find(
|
||||
const selectDefaultOption = () => {
|
||||
const popularOption = topupOptions.value.find(
|
||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||
);
|
||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||
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 = () => {
|
||||
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;
|
||||
@@ -127,32 +149,58 @@ defineExpose({ open, close });
|
||||
:width="dialogWidth"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step 1: Select Credits Package -->
|
||||
<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)"
|
||||
<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"
|
||||
/>
|
||||
</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 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>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Confirm Purchase -->
|
||||
@@ -178,7 +226,7 @@ defineExpose({ open, close });
|
||||
<template #footer>
|
||||
<!-- Step 1 Footer -->
|
||||
<div
|
||||
v-if="currentStep === 'select'"
|
||||
v-if="currentStep === STEP_SELECT"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
@@ -192,7 +240,7 @@ defineExpose({ open, close });
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
:disabled="!selectedCredits || isFetchingOptions || fetchError"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import { getCurrencyConfig } from 'dashboard/constants/billing';
|
||||
|
||||
const props = defineProps({
|
||||
targetCurrency: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const currencyLabel = computed(() =>
|
||||
t(getCurrencyConfig(props.targetCurrency).i18nLabelKey)
|
||||
);
|
||||
|
||||
const open = () => dialogRef.value?.open();
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.TITLE')"
|
||||
:description="
|
||||
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.DESCRIPTION', {
|
||||
currency: currencyLabel,
|
||||
})
|
||||
"
|
||||
:confirm-button-label="
|
||||
$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CONFIRM_BUTTON')
|
||||
"
|
||||
:cancel-button-label="$t('BILLING_SETTINGS.CURRENCY.CONFIRM.CANCEL_BUTTON')"
|
||||
:is-loading="isLoading"
|
||||
@confirm="emit('confirm')"
|
||||
>
|
||||
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
|
||||
<p class="text-sm text-n-amber-11">
|
||||
{{ $t('BILLING_SETTINGS.CURRENCY.CONFIRM.WARNING') }}
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -20,6 +20,7 @@ const state = {
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
isFetchingLimits: false,
|
||||
isSwitchingCurrency: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -152,6 +153,19 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
switchBillingCurrency: async ({ commit, dispatch }, { currency }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: true });
|
||||
try {
|
||||
await EnterpriseAccountAPI.switchCurrency(currency);
|
||||
// Refresh the account so billing_currency and subscription details reflect the switch.
|
||||
await dispatch('get', { silent: true });
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSwitchingCurrency: false });
|
||||
}
|
||||
},
|
||||
|
||||
limits: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
|
||||
MULTIPLE_CURRENCY_SUPPORTED: multipleCurrencySupported,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
const state = {
|
||||
@@ -51,6 +52,7 @@ const state = {
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
activePlatformBanners: activePlatformBanners || [],
|
||||
multipleCurrencySupported: parseBoolean(multipleCurrencySupported),
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
@@ -34,4 +34,12 @@ class AccountPolicy < ApplicationPolicy
|
||||
def topup_checkout?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def topup_options?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def switch_currency?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ 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)
|
||||
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?
|
||||
|
||||
@@ -253,10 +253,21 @@
|
||||
display_title: 'Cloud Plans'
|
||||
value:
|
||||
description: 'Config to store stripe plans for cloud'
|
||||
- name: CHATWOOT_CLOUD_TOPUP_OPTIONS
|
||||
display_title: 'Cloud 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: CHATWOOT_CLOUD_PLAN_FEATURES
|
||||
display_title: 'Planwise Features List'
|
||||
value:
|
||||
description: 'Config to features and their associated plans'
|
||||
- name: MULTIPLE_CURRENCY_SUPPORTED
|
||||
display_title: 'Multiple Currency Support'
|
||||
value: false
|
||||
description: 'Enable multi-currency billing: show the billing currency switch and onboard new pt_BR accounts in BRL'
|
||||
locked: false
|
||||
type: boolean
|
||||
- name: DEPLOYMENT_ENV
|
||||
value: self-hosted
|
||||
description: 'The deployment environment of the installation, to differentiate between Chatwoot cloud and self-hosted'
|
||||
|
||||
+11
-1
@@ -169,7 +169,17 @@ en:
|
||||
invalid_option: Invalid topup option
|
||||
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||
stripe_customer_not_configured: Stripe customer not configured
|
||||
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
||||
no_payment_method: No payment method available for your billing currency. Please add a card before making a purchase.
|
||||
billing:
|
||||
currency_required: Currency is required
|
||||
currency_switch_unavailable: Currency switching is not available for this account
|
||||
unsupported_currency: This currency is not supported
|
||||
same_currency: This account is already billed in the selected currency
|
||||
switch_in_progress: A currency switch is already in progress. Please try again in a moment.
|
||||
stripe_customer_not_configured: Stripe customer not configured
|
||||
switch_requires_active_subscription: Currency can only be changed when you have a single active subscription. Please resolve any pending billing first.
|
||||
unknown_plan: Could not determine the current plan
|
||||
currency_not_available_for_plan: The selected currency is not available for your current plan
|
||||
reports:
|
||||
date_range_too_long: Date range cannot exceed 6 months
|
||||
profile:
|
||||
|
||||
@@ -529,6 +529,8 @@ Rails.application.routes.draw do
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
post :topup_checkout
|
||||
get :topup_options
|
||||
post :switch_currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ 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]
|
||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion, :topup_options, :switch_currency]
|
||||
|
||||
def subscription
|
||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
||||
@@ -71,6 +71,22 @@ 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
|
||||
|
||||
def switch_currency
|
||||
return render json: { error: I18n.t('errors.billing.currency_required') }, status: :unprocessable_entity if params[:currency].blank?
|
||||
|
||||
Enterprise::Billing::SwitchCurrencyService.new(account: @account, currency: params[:currency]).perform
|
||||
|
||||
@account.reload
|
||||
render json: { id: @account.id, limits: @account.limits, custom_attributes: @account.custom_attributes }
|
||||
rescue Enterprise::Billing::SwitchCurrencyService::Error, Stripe::StripeError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_cloud_env
|
||||
|
||||
@@ -68,6 +68,19 @@ module Enterprise::Account
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
def billing_currency
|
||||
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); only new accounts infer from locale.
|
||||
return Enterprise::Billing::Currencies::DEFAULT if custom_attributes&.dig('stripe_customer_id').present?
|
||||
|
||||
# New accounts onboard in their locale's currency only while multi-currency billing is enabled.
|
||||
return Enterprise::Billing::Currencies::DEFAULT unless Enterprise::Billing::Currencies.multi_currency_supported?
|
||||
|
||||
Enterprise::Billing::Currencies.for_locale(locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_assignment_features
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Enterprise::Billing::CreateStripeCustomerService
|
||||
include BillingHelper
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
DEFAULT_QUANTITY = 2
|
||||
@@ -21,13 +23,22 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
|
||||
def prepare_customer_id
|
||||
customer_id = account.custom_attributes['stripe_customer_id']
|
||||
if customer_id.blank?
|
||||
customer = Stripe::Customer.create({ name: account.name, email: billing_email })
|
||||
customer_id = customer.id
|
||||
end
|
||||
customer_id = Stripe::Customer.create(customer_params).id if customer_id.blank?
|
||||
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
|
||||
@@ -37,13 +48,11 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
end
|
||||
|
||||
def default_plan
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
@default_plan ||= installation_config.value.first
|
||||
@default_plan ||= Enterprise::Billing::PlanConfiguration.default_plan
|
||||
end
|
||||
|
||||
def price_id
|
||||
price_ids = default_plan['price_ids']
|
||||
price_ids.first
|
||||
Enterprise::Billing::PlanConfiguration.price_id_for(default_plan, account.billing_currency)
|
||||
end
|
||||
|
||||
def active_subscription
|
||||
@@ -60,7 +69,7 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
end
|
||||
|
||||
def default_plan_subscription?(subscription)
|
||||
default_plan['price_ids'].include?(subscription['plan']['id'])
|
||||
Enterprise::Billing::PlanConfiguration.plan_contains_price_id?(default_plan, subscription['plan']['id'])
|
||||
end
|
||||
|
||||
def build_custom_attributes(customer_id, subscription)
|
||||
@@ -71,14 +80,8 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
'plan_name' => default_plan['name'],
|
||||
'subscribed_quantity' => subscription['quantity'],
|
||||
'subscription_status' => subscription['status'],
|
||||
'subscription_ends_on' => subscription_ends_on(subscription)
|
||||
'subscription_ends_on' => subscription_ends_on(subscription),
|
||||
'billing_currency' => account.billing_currency
|
||||
)
|
||||
end
|
||||
|
||||
def subscription_ends_on(subscription)
|
||||
period_end = subscription['current_period_end']
|
||||
return if period_end.blank?
|
||||
|
||||
Time.zone.at(period_end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Supported billing currencies and their Stripe/locale mappings.
|
||||
module Enterprise::Billing::Currencies
|
||||
DEFAULT = 'usd'.freeze
|
||||
|
||||
SUPPORTED = %w[usd brl].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
|
||||
|
||||
# Payment method types locked to one currency; types not listed (e.g. card) bill in any currency.
|
||||
CURRENCY_LOCKED_PAYMENT_METHOD_TYPES = {
|
||||
'pix' => 'brl',
|
||||
'boleto' => 'brl'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
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
|
||||
|
||||
# Master switch for multi-currency billing; read raw so a super-admin toggle isn't cache-stale.
|
||||
def multi_currency_supported?
|
||||
ActiveModel::Type::Boolean.new.cast(InstallationConfig.find_by(name: 'MULTIPLE_CURRENCY_SUPPORTED')&.value)
|
||||
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
|
||||
|
||||
# Can a payment method of this Stripe type bill the given currency?
|
||||
def payment_method_supports?(payment_method_type, code)
|
||||
locked_currency = CURRENCY_LOCKED_PAYMENT_METHOD_TYPES[payment_method_type.to_s]
|
||||
locked_currency.nil? || locked_currency == to_supported(code)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# Validates that an account may switch billing currency and returns the single switchable
|
||||
# subscription. Performs no Stripe mutations, so a rejected switch never touches billing state.
|
||||
class Enterprise::Billing::CurrencySwitchEligibility
|
||||
class Error < StandardError; end
|
||||
|
||||
# Stripe statuses that are done and can't reactivate — ignored when looking for the live subscription.
|
||||
TERMINAL_STATUSES = %w[canceled incomplete_expired].freeze
|
||||
|
||||
# Healthy statuses that may switch currency; trialing covers a sub left trialing by a prior paid switch.
|
||||
SWITCHABLE_STATUSES = %w[active trialing].freeze
|
||||
|
||||
pattr_initialize [:account!, :currency!]
|
||||
|
||||
# Returns the one live, switchable subscription (paid or default plan); raises otherwise.
|
||||
def subscription!
|
||||
validate!
|
||||
eligible_subscription!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate!
|
||||
raise Error, I18n.t('errors.billing.currency_switch_unavailable') unless Enterprise::Billing::Currencies.multi_currency_supported?
|
||||
raise Error, I18n.t('errors.billing.unsupported_currency') unless Enterprise::Billing::Currencies.supported?(currency)
|
||||
raise Error, I18n.t('errors.billing.same_currency') if target_currency == account.billing_currency
|
||||
raise Error, I18n.t('errors.billing.stripe_customer_not_configured') if stripe_customer_id.blank?
|
||||
end
|
||||
|
||||
# Exactly one live subscription in a switchable state; anything else is rejected before mutating Stripe.
|
||||
def eligible_subscription!
|
||||
subscription = live_subscriptions.first
|
||||
eligible = live_subscriptions.one? && SWITCHABLE_STATUSES.include?(subscription&.status)
|
||||
raise Error, I18n.t('errors.billing.switch_requires_active_subscription') unless eligible
|
||||
|
||||
subscription
|
||||
end
|
||||
|
||||
def target_currency
|
||||
@target_currency ||= Enterprise::Billing::Currencies.normalize(currency)
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
account.custom_attributes['stripe_customer_id']
|
||||
end
|
||||
|
||||
def live_subscriptions
|
||||
@live_subscriptions ||= Stripe::Subscription.list(customer: stripe_customer_id, status: 'all', limit: 100)
|
||||
.data.reject { |subscription| TERMINAL_STATUSES.include?(subscription.status) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# Makes the customer's default payment method one that can bill the given currency (PIX/boleto are
|
||||
# BRL-only). Drops an incompatible default, picks a compatible card, else falls back to default_source.
|
||||
class Enterprise::Billing::DefaultPaymentMethodReconciler
|
||||
pattr_initialize [:account!, :currency!]
|
||||
|
||||
# Returns the id of a currency-compatible default payment method, or nil if none is available.
|
||||
def reconcile
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
current_default = customer.invoice_settings.default_payment_method
|
||||
return current_default if compatible?(payment_methods.find { |method| method.id == current_default })
|
||||
|
||||
compatible = payment_methods.find { |method| compatible?(method) }
|
||||
return make_default(compatible.id) if compatible
|
||||
|
||||
# Drop the incompatible invoice default, then fall back to a legacy default_source card if present.
|
||||
unset_default if current_default.present?
|
||||
customer.default_source.presence
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def payment_methods
|
||||
@payment_methods ||= Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 100).data
|
||||
end
|
||||
|
||||
def compatible?(payment_method)
|
||||
payment_method.present? && Enterprise::Billing::Currencies.payment_method_supports?(payment_method.type, currency)
|
||||
end
|
||||
|
||||
def make_default(payment_method_id)
|
||||
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_method_id })
|
||||
payment_method_id
|
||||
end
|
||||
|
||||
def unset_default
|
||||
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: '' })
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
account.custom_attributes['stripe_customer_id']
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Enterprise::Billing::HandleStripeEventService
|
||||
include BillingHelper
|
||||
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
||||
|
||||
@@ -26,6 +28,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
|
||||
# skipping self hosted plan events
|
||||
return if plan.blank? || account.blank?
|
||||
# SwitchCurrencyService writes the final state itself — ignore its interim webhook events.
|
||||
return if currency_switch_cancellation?
|
||||
|
||||
previous_usage = capture_previous_usage
|
||||
update_account_attributes(subscription, plan)
|
||||
@@ -61,14 +65,30 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
'plan_name' => plan['name'],
|
||||
'subscribed_quantity' => subscription['quantity'],
|
||||
'subscription_status' => subscription['status'],
|
||||
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
|
||||
)
|
||||
'subscription_ends_on' => subscription_ends_on(subscription),
|
||||
'billing_currency' => billing_currency_for(subscription, plan)
|
||||
# Reconciling from Stripe is the final word on a currency switch — drop any in-flight marker.
|
||||
).except(Enterprise::Billing::SwitchCurrencyService::PENDING_CURRENCY_KEY)
|
||||
)
|
||||
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')
|
||||
|
||||
_plan, inferred_currency = Enterprise::Billing::PlanConfiguration.find_plan_by_price_id(subscription['plan']['id'])
|
||||
inferred_currency || account.billing_currency
|
||||
end
|
||||
|
||||
def currency_switch_cancellation?
|
||||
subscription['metadata'][Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY] == 'true'
|
||||
end
|
||||
|
||||
def process_subscription_deleted
|
||||
# skipping self hosted plan events
|
||||
return if account.blank?
|
||||
# A currency switch handles its own re-subscription; skip to avoid a stray default-plan sub.
|
||||
return if currency_switch_cancellation?
|
||||
|
||||
previous_monthly_credits = current_plan_credits[:responses]
|
||||
return unless Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
@@ -140,8 +160,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||
def find_plan(product_id)
|
||||
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(product_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
def plan_contains_price_id?(plan, price_id)
|
||||
price_ids_by_currency(plan).values.flatten.compact.include?(price_id)
|
||||
end
|
||||
|
||||
def default_plan?(plan)
|
||||
plan.present? && plan['name'] == default_plan&.dig('name')
|
||||
end
|
||||
|
||||
# [plan, currency] for a price id, else [nil, nil].
|
||||
def find_plan_by_price_id(price_id)
|
||||
plans.each do |plan|
|
||||
price_ids_by_currency(plan).each do |currency, ids|
|
||||
return [plan, currency] if ids.include?(price_id)
|
||||
end
|
||||
end
|
||||
[nil, nil]
|
||||
end
|
||||
|
||||
def find_plan_by_name(name)
|
||||
plans.find { |plan| plan['name'] == name }
|
||||
end
|
||||
|
||||
def find_plan_by_product_id(product_id)
|
||||
plans.find { |plan| Array(plan['product_id']).include?(product_id) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
# Resolves the current plan and the target-currency price id for a currency switch.
|
||||
class Enterprise::Billing::PlanPriceResolver
|
||||
class Error < StandardError; end
|
||||
|
||||
pattr_initialize [:subscription!, :target_currency!]
|
||||
|
||||
def plan
|
||||
@plan ||= resolve_plan
|
||||
end
|
||||
|
||||
def target_price_id
|
||||
by_currency = Enterprise::Billing::PlanConfiguration.price_ids_by_currency(plan)
|
||||
target_prices = by_currency[target_currency]
|
||||
raise Error, I18n.t('errors.billing.currency_not_available_for_plan') if target_prices.blank?
|
||||
|
||||
# Map by the current price's index within its currency so cadence (monthly/annual) is preserved.
|
||||
source_prices = by_currency.values.find { |ids| ids.include?(current_price_id) } || []
|
||||
index = source_prices.index(current_price_id) || 0
|
||||
target_prices[index] || target_prices.first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_price_id
|
||||
subscription['plan']['id']
|
||||
end
|
||||
|
||||
def resolve_plan
|
||||
plan, = Enterprise::Billing::PlanConfiguration.find_plan_by_price_id(current_price_id)
|
||||
plan ||= Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(subscription['plan']['product'])
|
||||
raise Error, I18n.t('errors.billing.unknown_plan') if plan.blank?
|
||||
|
||||
plan
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
# Stripe-side currency switch. Stripe forbids two currencies on one customer, so the old subscription
|
||||
# is cancelled before the new one is created; on a create failure the original is re-created.
|
||||
class Enterprise::Billing::StripeCurrencySwitchExecutor
|
||||
class Error < StandardError; end
|
||||
|
||||
pattr_initialize [:account!, :target_currency!]
|
||||
|
||||
# Returns the newly-created Stripe subscription.
|
||||
def execute(subscription:, change:)
|
||||
reconcile_default_payment_method unless change[:default_plan]
|
||||
|
||||
previous_currency = account.billing_currency
|
||||
sync_customer_location(target_currency)
|
||||
|
||||
begin
|
||||
replace_subscription(subscription, change)
|
||||
rescue StandardError
|
||||
# Swap reverted to the old currency — undo the location change too.
|
||||
sync_customer_location(previous_currency)
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def replace_subscription(subscription, change)
|
||||
cancel_subscription(subscription)
|
||||
create_or_revert(change)
|
||||
rescue Stripe::StripeError => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
def create_or_revert(change)
|
||||
create_currency_subscription(change[:new_price_id], change, idempotency_key)
|
||||
rescue Stripe::StripeError
|
||||
# Old sub already cancelled; re-create the original to keep the customer subscribed, then re-raise.
|
||||
create_currency_subscription(change[:original_price_id], change, revert_idempotency_key)
|
||||
raise
|
||||
end
|
||||
|
||||
def cancel_subscription(subscription)
|
||||
Stripe::Subscription.update(subscription.id, metadata: { Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY => 'true' })
|
||||
Stripe::Subscription.cancel(subscription.id, { prorate: false })
|
||||
rescue Stripe::StripeError
|
||||
# Clear the flag so a still-live sub isn't permanently skipped by the webhook guard.
|
||||
Stripe::Subscription.update(subscription.id, metadata: { Enterprise::Billing::SwitchCurrencyService::SWITCH_METADATA_KEY => '' })
|
||||
raise
|
||||
end
|
||||
|
||||
def create_currency_subscription(price_id, change, idempotency_key)
|
||||
params = { customer: stripe_customer_id, items: [{ price: price_id, quantity: change[:quantity] }] }
|
||||
# trial_end preserves already-paid time so switching mid-cycle doesn't double-charge.
|
||||
params[:trial_end] = change[:paid_through] if change[:paid_through].present? && change[:paid_through] > Time.current.to_i
|
||||
Stripe::Subscription.create(params, { idempotency_key: idempotency_key })
|
||||
end
|
||||
|
||||
# Fresh per attempt so a retry never replays a cancelled sub, and revert never collides with the create.
|
||||
def attempt_token
|
||||
@attempt_token ||= SecureRandom.uuid
|
||||
end
|
||||
|
||||
def idempotency_key
|
||||
"switch-#{account.id}-#{attempt_token}"
|
||||
end
|
||||
|
||||
def revert_idempotency_key
|
||||
"switch-revert-#{account.id}-#{attempt_token}"
|
||||
end
|
||||
|
||||
def reconcile_default_payment_method
|
||||
Enterprise::Billing::DefaultPaymentMethodReconciler.new(account: account, currency: target_currency).reconcile
|
||||
end
|
||||
|
||||
# Push the country override for currencies that need one (BRL/PIX); clear it otherwise so switching
|
||||
# to usd doesn't leave a stale BR address.
|
||||
def sync_customer_location(currency_code)
|
||||
country = Enterprise::Billing::Currencies.country_for(currency_code)
|
||||
locale = Enterprise::Billing::Currencies.preferred_locale_for(currency_code)
|
||||
|
||||
Stripe::Customer.update(
|
||||
stripe_customer_id,
|
||||
address: { country: country.presence || '' },
|
||||
preferred_locales: locale.present? ? [locale] : []
|
||||
)
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
account.custom_attributes['stripe_customer_id']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
# Orchestrates a billing currency switch: lock -> eligibility -> resolve price -> Stripe swap -> persist.
|
||||
class Enterprise::Billing::SwitchCurrencyService
|
||||
include BillingHelper
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
# Tags a cancelled sub so the deleted-webhook skips re-subscribing the default plan.
|
||||
SWITCH_METADATA_KEY = 'chatwoot_currency_switch'.freeze
|
||||
|
||||
# Marks an in-flight switch to reject concurrent requests; cleared on completion or by the webhook.
|
||||
PENDING_CURRENCY_KEY = 'billing_currency_switch_pending'.freeze
|
||||
|
||||
# A pending marker older than this is treated as abandoned so a crashed switch can't block forever.
|
||||
STALE_SWITCH_SECONDS = 10.minutes.to_i
|
||||
|
||||
pattr_initialize [:account!, :currency!]
|
||||
|
||||
def perform
|
||||
acquire_switch_lock!
|
||||
|
||||
begin
|
||||
subscription = eligibility.subscription!
|
||||
resolver = Enterprise::Billing::PlanPriceResolver.new(subscription: subscription, target_currency: target_currency)
|
||||
plan = resolver.plan
|
||||
change = change_for(subscription, resolver.target_price_id, default_plan: Enterprise::Billing::PlanConfiguration.default_plan?(plan))
|
||||
|
||||
new_subscription = executor.execute(subscription: subscription, change: change)
|
||||
|
||||
persist_currency(build_custom_attributes(new_subscription, plan))
|
||||
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
|
||||
rescue Enterprise::Billing::CurrencySwitchEligibility::Error,
|
||||
Enterprise::Billing::PlanPriceResolver::Error,
|
||||
Enterprise::Billing::StripeCurrencySwitchExecutor::Error => e
|
||||
# Swap self-reverted; drop the marker and surface a single error type.
|
||||
clear_pending
|
||||
raise Error, e.message
|
||||
rescue Stripe::StripeError
|
||||
# Preflight Stripe failure (before any subscription change); clear the marker so a blip can't lock switching.
|
||||
clear_pending
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Check-and-set the marker under a row lock so concurrent switches can't both create a subscription.
|
||||
def acquire_switch_lock!
|
||||
account.with_lock do
|
||||
raise Error, I18n.t('errors.billing.switch_in_progress') if switch_in_progress?
|
||||
|
||||
account.update!(custom_attributes: account.custom_attributes.merge(
|
||||
PENDING_CURRENCY_KEY => { 'currency' => target_currency, 'started_at' => Time.current.to_i }
|
||||
))
|
||||
end
|
||||
end
|
||||
|
||||
def switch_in_progress?
|
||||
marker = account.custom_attributes[PENDING_CURRENCY_KEY]
|
||||
return false if marker.blank?
|
||||
|
||||
started_at = marker.is_a?(Hash) ? marker['started_at'].to_i : 0
|
||||
Time.current.to_i - started_at < STALE_SWITCH_SECONDS
|
||||
end
|
||||
|
||||
def eligibility
|
||||
@eligibility ||= Enterprise::Billing::CurrencySwitchEligibility.new(account: account, currency: currency)
|
||||
end
|
||||
|
||||
def executor
|
||||
@executor ||= Enterprise::Billing::StripeCurrencySwitchExecutor.new(account: account, target_currency: target_currency)
|
||||
end
|
||||
|
||||
def target_currency
|
||||
@target_currency ||= Enterprise::Billing::Currencies.normalize(currency)
|
||||
end
|
||||
|
||||
def change_for(subscription, new_price_id, default_plan:)
|
||||
{
|
||||
new_price_id: new_price_id,
|
||||
# Needed to re-create the subscription if the new-currency create fails.
|
||||
original_price_id: subscription['plan']['id'],
|
||||
quantity: subscription['quantity'],
|
||||
# Paid plans trial until paid-through; the free default plan switches immediately.
|
||||
paid_through: default_plan ? nil : subscription_period_end(subscription),
|
||||
default_plan: default_plan
|
||||
}
|
||||
end
|
||||
|
||||
def build_custom_attributes(subscription, plan)
|
||||
account.custom_attributes.merge(
|
||||
'billing_currency' => target_currency,
|
||||
'stripe_price_id' => subscription['plan']['id'],
|
||||
'stripe_product_id' => subscription['plan']['product'],
|
||||
'plan_name' => plan['name'],
|
||||
'subscribed_quantity' => subscription['quantity'],
|
||||
'subscription_status' => subscription['status'],
|
||||
'subscription_ends_on' => subscription_ends_on(subscription)
|
||||
)
|
||||
end
|
||||
|
||||
def clear_pending
|
||||
account.update!(custom_attributes: account.custom_attributes.except(PENDING_CURRENCY_KEY))
|
||||
end
|
||||
|
||||
def persist_currency(custom_attributes)
|
||||
account.update!(custom_attributes: custom_attributes.except(PENDING_CURRENCY_KEY))
|
||||
end
|
||||
end
|
||||
@@ -3,15 +3,15 @@ class Enterprise::Billing::TopupCheckoutService
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
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
|
||||
TOPUP_OPTIONS_CONFIG = 'CHATWOOT_CLOUD_TOPUP_OPTIONS'.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)
|
||||
@@ -34,22 +34,15 @@ class Enterprise::Billing::TopupCheckoutService
|
||||
topup_option = find_topup_option(credits)
|
||||
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
|
||||
|
||||
# Validate payment method exists
|
||||
# Ensure a default payment method that can bill the account's currency (PIX can't pay a USD invoice).
|
||||
validate_payment_method!
|
||||
|
||||
topup_option
|
||||
end
|
||||
|
||||
def validate_payment_method!
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
|
||||
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
|
||||
|
||||
# Auto-set first payment method as default if available
|
||||
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
|
||||
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
|
||||
|
||||
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
|
||||
reconciler = Enterprise::Billing::DefaultPaymentMethodReconciler.new(account: account, currency: account.billing_currency)
|
||||
raise Error, I18n.t('errors.topup.no_payment_method') if reconciler.reconcile.blank?
|
||||
end
|
||||
|
||||
def charge_customer(topup_option, credits)
|
||||
@@ -100,6 +93,20 @@ class Enterprise::Billing::TopupCheckoutService
|
||||
end
|
||||
|
||||
def find_topup_option(credits)
|
||||
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
|
||||
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 || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -256,6 +256,14 @@ 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: 'CHATWOOT_CLOUD_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
|
||||
@@ -278,6 +286,7 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
limits: { 'captain_responses' => 1000 }
|
||||
)
|
||||
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([Struct.new(:id, :type).new('pm_test123', 'card')]))
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
|
||||
@@ -11,6 +11,34 @@ RSpec.describe Account, type: :model do
|
||||
it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) }
|
||||
end
|
||||
|
||||
describe '#billing_currency' do
|
||||
let(:account) { create(:account, locale: 'pt_BR') }
|
||||
|
||||
it 'returns the stored currency when set' do
|
||||
account.update!(custom_attributes: { 'billing_currency' => 'brl' })
|
||||
expect(account.billing_currency).to eq('brl')
|
||||
end
|
||||
|
||||
it 'keeps existing stripe customers on usd' do
|
||||
account.update!(custom_attributes: { 'stripe_customer_id' => 'cus_123' })
|
||||
expect(account.billing_currency).to eq('usd')
|
||||
end
|
||||
|
||||
context 'when multi-currency billing is enabled' do
|
||||
before { create(:installation_config, name: 'MULTIPLE_CURRENCY_SUPPORTED', value: true) }
|
||||
|
||||
it 'onboards a new pt_BR account in brl' do
|
||||
expect(account.billing_currency).to eq('brl')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multi-currency billing is disabled' do
|
||||
it 'onboards a new pt_BR account in usd' do
|
||||
expect(account.billing_currency).to eq('usd')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_policies' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
@@ -82,7 +82,8 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name',
|
||||
subscription_status: 'active',
|
||||
subscription_ends_on: subscription_ends_on
|
||||
subscription_ends_on: subscription_ends_on,
|
||||
billing_currency: 'usd'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
@@ -95,7 +96,9 @@ 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 }] })
|
||||
@@ -108,10 +111,25 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name',
|
||||
subscription_status: 'active',
|
||||
subscription_ends_on: subscription_ends_on
|
||||
subscription_ends_on: subscription_ends_on,
|
||||
billing_currency: 'usd'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
|
||||
it 'sets the billing country override when the account currency requires it' 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
|
||||
|
||||
describe 'when checking for existing subscriptions' do
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
@@ -36,6 +36,7 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
allow(subscription).to receive(:[]).with('quantity').and_return('10')
|
||||
allow(subscription).to receive(:[]).with('status').and_return('active')
|
||||
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
|
||||
allow(subscription).to receive(:[]).with('metadata').and_return({})
|
||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
||||
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::PlanPriceResolver do
|
||||
subject(:resolver) { described_class.new(subscription: subscription, target_currency: 'brl') }
|
||||
|
||||
let(:subscription) do
|
||||
Stripe::Subscription.construct_from(plan: { id: current_price_id, product: 'prod_business' })
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'],
|
||||
'price_ids' => { 'usd' => %w[price_monthly_usd price_annual_usd], 'brl' => %w[price_monthly_brl price_annual_brl] } }
|
||||
])
|
||||
end
|
||||
|
||||
describe '#target_price_id' do
|
||||
context 'when the current price is the first (monthly) in its currency' do
|
||||
let(:current_price_id) { 'price_monthly_usd' }
|
||||
|
||||
it 'maps to the same cadence in the target currency' do
|
||||
expect(resolver.target_price_id).to eq('price_monthly_brl')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the current price is the second (annual) in its currency' do
|
||||
let(:current_price_id) { 'price_annual_usd' }
|
||||
|
||||
it 'maps to the matching cadence rather than the first target price' do
|
||||
expect(resolver.target_price_id).to eq('price_annual_brl')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the target currency is not configured for the plan' do
|
||||
let(:current_price_id) { 'price_monthly_usd' }
|
||||
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS').update!(value: [
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'],
|
||||
'price_ids' => { 'usd' => ['price_monthly_usd'] } }
|
||||
])
|
||||
end
|
||||
|
||||
it 'raises' do
|
||||
expect { resolver.target_price_id }.to raise_error(described_class::Error, I18n.t('errors.billing.currency_not_available_for_plan'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#plan' do
|
||||
let(:current_price_id) { 'price_monthly_usd' }
|
||||
|
||||
it 'resolves the plan that owns the current price' do
|
||||
expect(resolver.plan['name']).to eq('Business')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,283 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::SwitchCurrencyService do
|
||||
subject(:service) { described_class.new(account: account, currency: target_currency) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:target_currency) { 'brl' }
|
||||
let(:stripe_customer_id) { 'cus_test123' }
|
||||
let(:period_end) { 1.month.from_now.to_i }
|
||||
|
||||
let(:active_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_usd', status: 'active', quantity: 2, current_period_end: period_end,
|
||||
plan: { id: 'price_business_usd', product: 'prod_business' }, metadata: {}
|
||||
)
|
||||
end
|
||||
|
||||
let(:new_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_brl', status: 'trialing', quantity: 2, current_period_end: period_end,
|
||||
plan: { id: 'price_business_brl', product: 'prod_business' }, metadata: {}
|
||||
)
|
||||
end
|
||||
|
||||
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test') }
|
||||
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
|
||||
let(:default_payment_methods) { [Struct.new(:id, :type).new('pm_test', 'card')] }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => { 'usd' => ['price_hacker_usd'], 'brl' => ['price_hacker_brl'] } },
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'],
|
||||
'price_ids' => { 'usd' => ['price_business_usd'], 'brl' => ['price_business_brl'] } }
|
||||
])
|
||||
|
||||
create(:installation_config, name: 'MULTIPLE_CURRENCY_SUPPORTED', value: true)
|
||||
account.update!(custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id, billing_currency: 'usd' })
|
||||
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(Struct.new(:data).new([active_subscription]))
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(new_subscription)
|
||||
allow(Stripe::Subscription).to receive(:update)
|
||||
allow(Stripe::Subscription).to receive(:cancel)
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
|
||||
allow(Stripe::Customer).to receive(:update)
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new(default_payment_methods))
|
||||
|
||||
reconcile = instance_double(Enterprise::Billing::ReconcilePlanFeaturesService, perform: true)
|
||||
allow(Enterprise::Billing::ReconcilePlanFeaturesService).to receive(:new).and_return(reconcile)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'cancels the old subscription before creating the new-currency one' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:cancel).with('sub_usd', { prorate: false }).ordered
|
||||
expect(Stripe::Subscription).to have_received(:create).with(
|
||||
hash_including(customer: stripe_customer_id, items: [{ price: 'price_business_brl', quantity: 2 }]),
|
||||
hash_including(:idempotency_key)
|
||||
).ordered
|
||||
end
|
||||
|
||||
it 'uses a per-attempt idempotency key not derived from the subscription id' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:create).with(
|
||||
anything, hash_including(idempotency_key: a_string_matching(/\Aswitch-#{account.id}-[0-9a-f-]{36}\z/))
|
||||
)
|
||||
end
|
||||
|
||||
it 'persists the new currency and clears the pending marker' do
|
||||
service.perform
|
||||
|
||||
attributes = account.reload.custom_attributes
|
||||
expect(attributes['billing_currency']).to eq('brl')
|
||||
expect(attributes['stripe_price_id']).to eq('price_business_brl')
|
||||
expect(attributes).not_to have_key('billing_currency_switch_pending')
|
||||
end
|
||||
|
||||
it 'tags the cancelled subscription so the webhook skips re-subscribing the default plan' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:update).with(
|
||||
'sub_usd', metadata: { described_class::SWITCH_METADATA_KEY => 'true' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises when multi-currency billing is not enabled' do
|
||||
InstallationConfig.find_by(name: 'MULTIPLE_CURRENCY_SUPPORTED').update!(value: false)
|
||||
|
||||
expect { service.perform }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Enterprise::Billing::SwitchCurrencyService::Error')
|
||||
expect(error.message).to eq(I18n.t('errors.billing.currency_switch_unavailable'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises for an unsupported currency' do
|
||||
service = described_class.new(account: account, currency: 'eur')
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.unsupported_currency'))
|
||||
end
|
||||
|
||||
it 'raises when switching to the currency already in use' do
|
||||
service = described_class.new(account: account, currency: 'usd')
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.same_currency'))
|
||||
end
|
||||
|
||||
it 'raises when no stripe customer is configured' do
|
||||
account.update!(custom_attributes: { plan_name: 'Business', billing_currency: 'usd' })
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.stripe_customer_not_configured'))
|
||||
end
|
||||
|
||||
it 'raises when more than one live subscription exists' do
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(Struct.new(:data).new([active_subscription, new_subscription]))
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.switch_requires_active_subscription'))
|
||||
end
|
||||
|
||||
it 'rejects a switch while another is already in progress and leaves the marker intact' do
|
||||
account.update!(custom_attributes: account.custom_attributes.merge(
|
||||
'billing_currency_switch_pending' => { 'currency' => 'brl', 'started_at' => Time.current.to_i }
|
||||
))
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.switch_in_progress'))
|
||||
expect(Stripe::Subscription).not_to have_received(:create)
|
||||
expect(account.reload.custom_attributes['billing_currency_switch_pending']).to be_present
|
||||
end
|
||||
|
||||
it 'clears the pending marker when a stripe preflight call fails' do
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_raise(Stripe::StripeError.new('customer temporarily unavailable'))
|
||||
|
||||
expect { service.perform }.to raise_error(Stripe::StripeError)
|
||||
expect(account.reload.custom_attributes).not_to have_key('billing_currency_switch_pending')
|
||||
end
|
||||
|
||||
it 'proceeds when the in-progress marker is stale' do
|
||||
account.update!(custom_attributes: account.custom_attributes.merge(
|
||||
'billing_currency_switch_pending' => { 'currency' => 'brl', 'started_at' => 1.hour.ago.to_i }
|
||||
))
|
||||
|
||||
expect { service.perform }.not_to raise_error
|
||||
expect(account.reload.custom_attributes['billing_currency']).to eq('brl')
|
||||
end
|
||||
|
||||
it 'raises when the target currency is not configured for the plan' do
|
||||
InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS').update!(value: [
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'],
|
||||
'price_ids' => { 'usd' => ['price_business_usd'] } }
|
||||
])
|
||||
|
||||
expect { service.perform }.to raise_error(described_class::Error, I18n.t('errors.billing.currency_not_available_for_plan'))
|
||||
end
|
||||
|
||||
it 'completes the switch without a payment method (the user is prompted later)' do
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(Struct.new(:invoice_settings, :default_source).new(
|
||||
Struct.new(:default_payment_method).new(nil), nil
|
||||
))
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([]))
|
||||
|
||||
expect { service.perform }.not_to raise_error
|
||||
expect(account.reload.custom_attributes['billing_currency']).to eq('brl')
|
||||
end
|
||||
|
||||
context 'when creating the new-currency subscription fails' do
|
||||
before do
|
||||
# Only the new (brl) create fails; re-creating the original (usd) succeeds.
|
||||
allow(Stripe::Subscription).to receive(:create) do |params, _opts|
|
||||
raise Stripe::StripeError, 'cannot combine currencies' if params[:items].first[:price] == 'price_business_brl'
|
||||
|
||||
active_subscription
|
||||
end
|
||||
end
|
||||
|
||||
it 'cancels the old subscription then re-creates the original to restore service' do
|
||||
expect { service.perform }.to raise_error(described_class::Error)
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:cancel).with('sub_usd', { prorate: false })
|
||||
expect(Stripe::Subscription).to have_received(:create).with(
|
||||
hash_including(items: [{ price: 'price_business_usd', quantity: 2 }]), anything
|
||||
)
|
||||
end
|
||||
|
||||
it 'keeps the account on the original currency and clears the pending marker' do
|
||||
expect { service.perform }.to raise_error(described_class::Error)
|
||||
|
||||
attributes = account.reload.custom_attributes
|
||||
expect(attributes['billing_currency']).to eq('usd')
|
||||
expect(attributes).not_to have_key('billing_currency_switch_pending')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a free default-plan subscription has a stale price id' do
|
||||
# Price id no longer in CHATWOOT_CLOUD_PLANS, but product_id still maps to the default (Hacker) plan.
|
||||
let(:active_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_hacker', status: 'active', quantity: 1, current_period_end: period_end,
|
||||
plan: { id: 'price_hacker_legacy_usd', product: 'prod_hacker' }, metadata: {}
|
||||
)
|
||||
end
|
||||
let(:new_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_hacker_brl', status: 'active', quantity: 1, current_period_end: period_end,
|
||||
plan: { id: 'price_hacker_brl', product: 'prod_hacker' }, metadata: {}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id, billing_currency: 'usd' })
|
||||
# No payment method available — a default-plan switch must not require one.
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(
|
||||
Struct.new(:invoice_settings, :default_source).new(Struct.new(:default_payment_method).new(nil), nil)
|
||||
)
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([]))
|
||||
end
|
||||
|
||||
it 'switches without requiring a payment method or a paid-through trial' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).not_to have_received(:retrieve)
|
||||
expect(Stripe::Subscription).to have_received(:create).with(hash_not_including(:trial_end), anything)
|
||||
expect(account.reload.custom_attributes['billing_currency']).to eq('brl')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when switching from brl to usd' do
|
||||
let(:target_currency) { 'usd' }
|
||||
let(:active_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_brl', status: 'active', quantity: 2, current_period_end: period_end,
|
||||
plan: { id: 'price_business_brl', product: 'prod_business' }, metadata: {}
|
||||
)
|
||||
end
|
||||
let(:new_subscription) do
|
||||
Stripe::Subscription.construct_from(
|
||||
id: 'sub_usd', status: 'trialing', quantity: 2, current_period_end: period_end,
|
||||
plan: { id: 'price_business_usd', product: 'prod_business' }, metadata: {}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id, billing_currency: 'brl' })
|
||||
end
|
||||
|
||||
it 'clears the stripe billing country and locale override' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:update).with(
|
||||
stripe_customer_id, hash_including(address: { country: '' }, preferred_locales: [])
|
||||
)
|
||||
expect(account.reload.custom_attributes['billing_currency']).to eq('usd')
|
||||
end
|
||||
|
||||
context 'when the default payment method cannot bill the new currency' do
|
||||
let(:pix) { Struct.new(:id, :type).new('pm_pix', 'pix') }
|
||||
let(:card) { Struct.new(:id, :type).new('pm_card', 'card') }
|
||||
|
||||
before do
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(
|
||||
Struct.new(:invoice_settings, :default_source).new(Struct.new(:default_payment_method).new('pm_pix'), nil)
|
||||
)
|
||||
end
|
||||
|
||||
it 'switches the default to an attached compatible card' do
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([pix, card]))
|
||||
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:update).with(stripe_customer_id, invoice_settings: { default_payment_method: 'pm_card' })
|
||||
end
|
||||
|
||||
it 'unsets the default when no compatible method is attached' do
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([pix]))
|
||||
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:update).with(stripe_customer_id, invoice_settings: { default_payment_method: '' })
|
||||
expect(account.reload.custom_attributes['billing_currency']).to eq('usd')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -15,12 +15,22 @@ describe Enterprise::Billing::TopupCheckoutService do
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||
])
|
||||
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_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 }
|
||||
)
|
||||
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([Struct.new(:id, :type).new('pm_test', 'card')]))
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
@@ -44,6 +54,15 @@ describe Enterprise::Billing::TopupCheckoutService do
|
||||
expect(account.reload.limits['captain_responses']).to eq(1500)
|
||||
end
|
||||
|
||||
it 'charges via a legacy default source when there is no invoice default or payment method' do
|
||||
allow(Stripe::Customer).to receive(:retrieve).and_return(
|
||||
Struct.new(:invoice_settings, :default_source).new(Struct.new(:default_payment_method).new(nil), 'card_legacy')
|
||||
)
|
||||
allow(Stripe::PaymentMethod).to receive(:list).and_return(Struct.new(:data).new([]))
|
||||
|
||||
expect(service.create_checkout_session(credits: 1000)[:credits]).to eq(1000)
|
||||
end
|
||||
|
||||
it 'raises error for invalid credits' do
|
||||
expect { service.create_checkout_session(credits: 500) }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error')
|
||||
|
||||
Reference in New Issue
Block a user