From b38618b5d3f630f17fbc4aab5502d53ec2fd7b96 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 2 Jun 2026 15:36:27 +0530 Subject: [PATCH] feat(billing): bill new Brazilian accounts in BRL and show currency-aware credit top-ups --- app/helpers/billing_helper.rb | 14 ++ .../dashboard/api/enterprise/account.js | 6 + app/javascript/dashboard/constants/billing.js | 35 +++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../billing/components/CreditPackageCard.vue | 8 +- .../components/PurchaseCreditsModal.vue | 135 ++++++++++++------ app/policies/account_policy.rb | 4 + config/installation_config.yml | 5 + config/routes.rb | 1 + .../enterprise/api/v1/accounts_controller.rb | 7 +- enterprise/app/models/enterprise/account.rb | 10 ++ .../billing/create_stripe_customer_service.rb | 29 ++-- .../services/enterprise/billing/currencies.rb | 53 +++++++ .../billing/handle_stripe_event_service.rb | 18 ++- .../enterprise/billing/plan_configuration.rb | 64 +++++++++ .../billing/topup_checkout_service.rb | 40 +++++- .../create_stripe_customer_service_spec.rb | 10 +- 17 files changed, 364 insertions(+), 76 deletions(-) create mode 100644 app/javascript/dashboard/constants/billing.js create mode 100644 enterprise/app/services/enterprise/billing/currencies.rb create mode 100644 enterprise/app/services/enterprise/billing/plan_configuration.rb diff --git a/app/helpers/billing_helper.rb b/app/helpers/billing_helper.rb index e2ada7e86..f7538cc01 100644 --- a/app/helpers/billing_helper.rb +++ b/app/helpers/billing_helper.rb @@ -22,4 +22,18 @@ module BillingHelper def agents(account) account.users.count end + + # current_period_end moved from the subscription top-level to the subscription + # item in recent Stripe API versions — read both so the paid-through date is + # never lost. + 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 diff --git a/app/javascript/dashboard/api/enterprise/account.js b/app/javascript/dashboard/api/enterprise/account.js index 9e6d40a62..a377468f4 100644 --- a/app/javascript/dashboard/api/enterprise/account.js +++ b/app/javascript/dashboard/api/enterprise/account.js @@ -27,6 +27,12 @@ class EnterpriseAccountAPI extends ApiClient { createTopupCheckout(credits) { return axios.post(`${this.url}topup_checkout`, { credits }); } + + // Returns { currency, options: [{ credits, amount, currency }] } for the + // account's billing currency, sourced from CHATWOOT_CLOUD_TOPUP_OPTIONS. + getTopupOptions() { + return axios.get(`${this.url}topup_options`); + } } export default new EnterpriseAccountAPI(); diff --git a/app/javascript/dashboard/constants/billing.js b/app/javascript/dashboard/constants/billing.js new file mode 100644 index 000000000..d372330f8 --- /dev/null +++ b/app/javascript/dashboard/constants/billing.js @@ -0,0 +1,35 @@ +// 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); +}; diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index bfbd920a7..4ab02b635 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -475,6 +475,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": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue index d557e9a71..59cf07c18 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/CreditPackageCard.vue @@ -1,4 +1,6 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue index 33f299b9b..8bc1f33d4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -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,21 @@ const selectedCredits = ref(null); const isLoading = ref(false); const currentStep = ref(STEP_SELECT); +// Topup packages come from the backend (CHATWOOT_CLOUD_TOPUP_OPTIONS) for the +// account's billing currency — only the relevant currency's options are shown. +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 +67,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 +150,58 @@ defineExpose({ open, close }); :width="dialogWidth" :show-confirm-button="false" :show-cancel-button="false" - @close="handleClose" > - @@ -178,7 +227,7 @@ defineExpose({ open, close });