feat(billing): bill new Brazilian accounts in BRL and show currency-aware credit top-ups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
+92
-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,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"
|
||||
>
|
||||
<!-- 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 +227,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 +241,7 @@ defineExpose({ open, close });
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
:disabled="!selectedCredits || isFetchingOptions || fetchError"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,4 +34,8 @@ class AccountPolicy < ApplicationPolicy
|
||||
def topup_checkout?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def topup_options?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -253,6 +253,11 @@
|
||||
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:
|
||||
|
||||
@@ -523,6 +523,7 @@ Rails.application.routes.draw do
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
post :topup_checkout
|
||||
get :topup_options
|
||||
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_checkout, :topup_options]
|
||||
|
||||
def subscription
|
||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
||||
@@ -71,6 +71,11 @@ 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
|
||||
|
||||
@@ -68,6 +68,16 @@ module Enterprise::Account
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
|
||||
# Effective billing currency. Mirrors the Stripe customer's currency once one
|
||||
# is set (kept in sync by the billing services + Stripe webhook); falls back
|
||||
# to the locale default (pt_BR => brl, else usd) for accounts without one yet.
|
||||
def billing_currency
|
||||
stored = custom_attributes&.dig('billing_currency')
|
||||
return Enterprise::Billing::Currencies.normalize(stored) if Enterprise::Billing::Currencies.supported?(stored)
|
||||
|
||||
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
|
||||
@@ -22,7 +24,14 @@ 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 = Stripe::Customer.create(
|
||||
{
|
||||
name: account.name,
|
||||
email: billing_email,
|
||||
address: { country: Enterprise::Billing::Currencies.country_for(account.billing_currency) },
|
||||
preferred_locales: [Enterprise::Billing::Currencies.preferred_locale_for(account.billing_currency)]
|
||||
}
|
||||
)
|
||||
customer_id = customer.id
|
||||
end
|
||||
customer_id
|
||||
@@ -37,13 +46,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 +67,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 +78,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,53 @@
|
||||
# Single source of truth for the billing currencies Chatwoot Cloud supports.
|
||||
# Adding a new currency (e.g. EUR) is a one-line edit here plus the matching
|
||||
# price_ids in CHATWOOT_CLOUD_PLANS and rates in CHATWOOT_CLOUD_TOPUP_OPTIONS.
|
||||
module Enterprise::Billing::Currencies
|
||||
DEFAULT = 'usd'.freeze
|
||||
|
||||
SUPPORTED = %w[usd brl].freeze
|
||||
|
||||
# Account locale label (the enum label, e.g. 'pt_BR') => default currency.
|
||||
# Anything not listed falls back to DEFAULT.
|
||||
LOCALE_DEFAULTS = {
|
||||
'pt_BR' => 'brl'
|
||||
}.freeze
|
||||
|
||||
# Used to keep the Stripe customer's location/currency in sync with the
|
||||
# account's billing currency.
|
||||
COUNTRY_BY_CURRENCY = {
|
||||
'usd' => 'US',
|
||||
'brl' => 'BR'
|
||||
}.freeze
|
||||
|
||||
PREFERRED_LOCALE_BY_CURRENCY = {
|
||||
'usd' => 'en',
|
||||
'brl' => 'pt-BR'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def normalize(code)
|
||||
code.to_s.strip.downcase.presence
|
||||
end
|
||||
|
||||
def supported?(code)
|
||||
SUPPORTED.include?(normalize(code))
|
||||
end
|
||||
|
||||
# Coerce arbitrary input to a usable supported code, else DEFAULT.
|
||||
def coerce(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[coerce(code)]
|
||||
end
|
||||
|
||||
def preferred_locale_for(code)
|
||||
PREFERRED_LOCALE_BY_CURRENCY[coerce(code)]
|
||||
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
|
||||
|
||||
@@ -61,11 +63,22 @@ 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)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Paid subscriptions are billed in real money, so their currency is
|
||||
# authoritative and overrides the stored value. The default/free plan is $0
|
||||
# and not currency-defining, so we preserve the account's chosen 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 process_subscription_deleted
|
||||
# skipping self hosted plan events
|
||||
return if account.blank?
|
||||
@@ -141,7 +154,6 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
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) }
|
||||
Enterprise::Billing::PlanConfiguration.find_plan_by_product_id(plan_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Reads CHATWOOT_CLOUD_PLANS and resolves Stripe price ids in a currency-aware,
|
||||
# backward-compatible way. Owns all plan-shape parsing so the currency logic
|
||||
# isn't scattered across the billing services.
|
||||
#
|
||||
# A plan's `price_ids` may be:
|
||||
# - a currency-keyed Hash: { 'usd' => ['price_x'], 'brl' => ['price_y'] }
|
||||
# - a flat Array (legacy): ['price_x'] -> treated as usd
|
||||
# - a bare String (legacy): 'price_x' -> 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 }
|
||||
when String then { Enterprise::Billing::Currencies::DEFAULT => [raw] }
|
||||
else {}
|
||||
end
|
||||
end
|
||||
|
||||
# Price id to subscribe `plan` in `currency`. Falls back to usd, then to any
|
||||
# configured price, so a free plan with only a usd price still resolves.
|
||||
def price_id_for(plan, currency)
|
||||
by_currency = price_ids_by_currency(plan)
|
||||
code = Enterprise::Billing::Currencies.coerce(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
|
||||
|
||||
# Webhook currency inference: [plan, currency] for a given price id, or [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
|
||||
@@ -3,15 +3,27 @@ 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
|
||||
|
||||
# Used only when CHATWOOT_CLOUD_TOPUP_OPTIONS is not configured, so the
|
||||
# billing page never breaks during rollout. Real rates live in the config.
|
||||
FALLBACK_OPTIONS = {
|
||||
'usd' => [
|
||||
{ 'credits' => 1000, 'amount' => 20.0 },
|
||||
{ 'credits' => 2500, 'amount' => 50.0 },
|
||||
{ 'credits' => 6000, 'amount' => 100.0 },
|
||||
{ 'credits' => 12_000, 'amount' => 200.0 }
|
||||
]
|
||||
}.freeze
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
# Topup packages for the account's billing currency, used by the controller
|
||||
# to render the same options the frontend offers.
|
||||
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,6 +112,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
|
||||
currency = account.billing_currency
|
||||
rows = configured_options[currency].presence || configured_options[Enterprise::Billing::Currencies::DEFAULT].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.presence || FALLBACK_OPTIONS
|
||||
rescue JSON::ParserError
|
||||
FALLBACK_OPTIONS
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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, address: { country: 'US' }, preferred_locales: ['en'] }
|
||||
)
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
@@ -108,7 +111,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
|
||||
|
||||
Reference in New Issue
Block a user