Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45af6266bb | ||
|
|
3ba4340042 | ||
|
|
411048715d | ||
|
|
9a7a2486e4 | ||
|
|
39af360a18 | ||
|
|
ac1743514b | ||
|
|
c1df37b41d | ||
|
|
7c3b07c507 | ||
|
|
f1f1ce644c | ||
|
|
38f16ba677 | ||
|
|
e7b01d80b3 | ||
|
|
ec9a82a017 | ||
|
|
62203da528 | ||
|
|
029294e16b | ||
|
|
2c095bed86 | ||
|
|
7c6508dd04 | ||
|
|
cef23891af | ||
|
|
19a851c01d | ||
|
|
a22e11c810 | ||
|
|
efe6e64dcd | ||
|
|
d6f5c627eb | ||
|
|
51b4a8f9fc | ||
|
|
1268b2cf55 | ||
|
|
125ae5523a | ||
|
|
59f60936a7 | ||
|
|
216a2c0e73 | ||
|
|
708296a746 | ||
|
|
b9168664f7 | ||
|
|
01cd7b878d | ||
|
|
acb2816826 |
@@ -70,6 +70,7 @@ jobs:
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
|
||||
@@ -99,3 +99,8 @@ CLAUDE.local.md
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
|
||||
.pnpm-store/
|
||||
|
||||
# External documentation
|
||||
\[External\]*
|
||||
|
||||
+1
-1
@@ -901,7 +901,7 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (8.5.0)
|
||||
stripe (16.0.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
|
||||
@@ -23,6 +23,35 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
|
||||
getCreditBalance() {
|
||||
return axios.get(`${this.url}credits_balance`);
|
||||
}
|
||||
|
||||
getV2PricingPlans() {
|
||||
return axios.get(`${this.url}v2_pricing_plans`);
|
||||
}
|
||||
|
||||
getV2TopupOptions() {
|
||||
return axios.get(`${this.url}v2_topup_options`);
|
||||
}
|
||||
|
||||
purchaseTopup(credits) {
|
||||
return axios.post(`${this.url}v2_topup`, { credits });
|
||||
}
|
||||
|
||||
subscribeToV2Plan(pricing_plan_id, quantity = 1) {
|
||||
return axios.post(`${this.url}v2_subscribe`, { pricing_plan_id, quantity });
|
||||
}
|
||||
|
||||
cancelSubscription(options = {}) {
|
||||
const { feedback = null, comment = null } = options;
|
||||
|
||||
return axios.post(`${this.url}cancel_subscription`, {
|
||||
feedback,
|
||||
comment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "Conversations by agents",
|
||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||
|
||||
@@ -395,7 +395,92 @@
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
"BUTTON_TXT": "Chat with us"
|
||||
},
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
|
||||
"V2_BILLING": {
|
||||
"TITLE": "Usage-based billing",
|
||||
"DESCRIPTION": "Choose the credit package that fits your team, manage usage-based billing, and keep an eye on your credit balance.",
|
||||
"MANAGE_PAYMENT_METHOD": "Manage payment method",
|
||||
"MANAGE_BILLING": "Manage billing",
|
||||
"MANAGE_SUBSCRIPTION": "Manage subscription",
|
||||
"CONTACT_SUPPORT": "Contact support",
|
||||
"BACK_TO_BILLING": "Back to Billing",
|
||||
"CURRENT_PLAN": "Current Plan",
|
||||
"NO_PLANS_AVAILABLE": "Pricing plans are not available right now. Please try again in a moment or contact support.",
|
||||
"PLAN_SECTION": {
|
||||
"TITLE": "Pick the right usage plan",
|
||||
"DESCRIPTION": "Switch plans anytime - changes take effect immediately and your credits update right away.",
|
||||
"HELP_TEXT": "Usage beyond the monthly credits is billed at the same overage rate across all plans."
|
||||
},
|
||||
"PLAN_CARD": {
|
||||
"DEFAULT_UNIT": "credits",
|
||||
"SUMMARY_WITH_BASE": "{credits} {creditUnit} included per month, {baseFee} base fee, {overage} per additional {creditUnit}.",
|
||||
"SUMMARY_FREE": "{credits} {creditUnit} included per month with no base fee. Pay {overage} per additional {creditUnit}.",
|
||||
"HIGHLIGHT_INCLUDED": "{credits} {unit} included every month",
|
||||
"HIGHLIGHT_OVERAGE": "{rate} per additional {unit}",
|
||||
"HIGHLIGHT_BASE_FEE": "{amount} monthly base fee",
|
||||
"HIGHLIGHT_NO_BASE_FEE": "No monthly base fee",
|
||||
"PRICE_FREE": "Free",
|
||||
"PER_MONTH": "per month",
|
||||
"BASE_FEE_LABEL": "Base fee",
|
||||
"BASE_FEE_VALUE": "{amount} / month",
|
||||
"BASE_FEE_VALUE_FREE": "No base fee",
|
||||
"MONTHLY_CREDITS_LABEL": "Monthly credits",
|
||||
"OVERAGE_RATE_LABEL": "Overage rate",
|
||||
"OVERAGE_RATE_VALUE": "{rate} / {unit}",
|
||||
"CTA_SELECT": "Select plan",
|
||||
"CTA_SWITCH": "Switch plan",
|
||||
"CTA_CURRENT": "Current plan"
|
||||
},
|
||||
"PLAN_UPDATE": {
|
||||
"SUCCESS": "You're now on the {plan} plan.",
|
||||
"ERROR": "We couldn't update the plan to {plan}. Please try again."
|
||||
},
|
||||
"CHECKOUT_SUCCESS": "Subscription successful! Your plan has been updated.",
|
||||
"CHECKOUT_CANCELLED": "Subscription cancelled. You can try again anytime.",
|
||||
"ERRORS": {
|
||||
"FETCH_PLANS": "Failed to load pricing plans. Please refresh and try again.",
|
||||
"PORTAL": "Failed to open the payment portal. Please try again.",
|
||||
"SUBSCRIPTION": "We couldn't update your plan. Please try again.",
|
||||
"TOPUP_OPTIONS": "We couldn't load top-up options. Please try again later.",
|
||||
"TOPUP": "Top-up failed. Please try again."
|
||||
},
|
||||
"CREDIT_BALANCE": {
|
||||
"TITLE": "Credit balance",
|
||||
"DESCRIPTION": "Track monthly and top-up credits available to your account.",
|
||||
"PLAN_NAME": "Current plan",
|
||||
"SUBSCRIBED_SEATS": "Subscribed seats",
|
||||
"MONTHLY_CREDITS": "Monthly credits",
|
||||
"TOPUP_CREDITS": "Top-up credits",
|
||||
"TOTAL_AVAILABLE": "Total available",
|
||||
"USAGE_THIS_MONTH": "Used this month",
|
||||
"USAGE_TOTAL": "Total usage"
|
||||
},
|
||||
"TOPUP": {
|
||||
"BUTTON": "Add credits",
|
||||
"TITLE": "Add top-up credits",
|
||||
"DESCRIPTION": "Choose a credit pack to increase your available balance immediately.",
|
||||
"CREDITS_LABEL": "credits",
|
||||
"INCLUDES": "{amount} billed immediately",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM": "Buy credits",
|
||||
"SUCCESS": "{credits} credits added successfully.",
|
||||
"ERROR": "We couldn't add {credits} credits. Please try again."
|
||||
},
|
||||
"CANCEL_SUBSCRIPTION": {
|
||||
"BUTTON_TXT": "Cancel subscription",
|
||||
"CONFIRM_TITLE": "Cancel subscription",
|
||||
"CONFIRM_DESCRIPTION": "Are you sure you want to cancel your subscription? Your subscription will remain active until the end of the current billing period.",
|
||||
"CONFIRM_LABEL": "Yes, cancel subscription",
|
||||
"CANCEL_LABEL": "No, keep subscription",
|
||||
"CONFIRM_MESSAGE": "Are you sure you want to cancel your subscription? Your subscription will remain active until the end of the current billing period.",
|
||||
"CONFIRM_IMMEDIATE_MESSAGE": "Are you sure you want to cancel your subscription immediately? You will lose access to all features immediately.",
|
||||
"CANCEL_AT_PERIOD_END": "Cancel at period end",
|
||||
"CANCEL_IMMEDIATELY": "Cancel immediately",
|
||||
"SUCCESS": "Subscription cancelled successfully. Your subscription will remain active until the end of the current billing period.",
|
||||
"SUCCESS_WITH_DATE": "Subscription cancelled. You'll retain access until {date}.",
|
||||
"ERROR": "Failed to cancel subscription"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SECURITY_SETTINGS": {
|
||||
"TITLE": "Security",
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
<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 { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
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 BillingTopupModal from './components/BillingTopupModal.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
@@ -30,6 +35,9 @@ const store = useStore();
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
const confirmationModal = ref(null);
|
||||
const isTopupModalOpen = ref(false);
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
|
||||
@@ -37,6 +45,14 @@ const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if this is a V2 billing customer
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isV2Billing = computed(() => {
|
||||
return customAttributes.value.stripe_billing_version === 2;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for plan name
|
||||
* @returns {string|undefined}
|
||||
@@ -62,13 +78,36 @@ const subscriptionRenewsOn = computed(() => {
|
||||
|
||||
/**
|
||||
* Computed property indicating if user has a billing plan
|
||||
* For V2 billing, we consider billing setup complete if stripe_billing_version is set
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasABillingPlan = computed(() => {
|
||||
// For V2 billing, account is considered set up if billing version is set
|
||||
if (isV2Billing.value) {
|
||||
return true;
|
||||
}
|
||||
// For V1 billing, need an actual plan name
|
||||
return !!planName.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if subscription is active and cancellable
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const canCancelSubscription = computed(() => {
|
||||
const status = customAttributes.value.subscription_status;
|
||||
return isV2Billing.value && status === 'active';
|
||||
});
|
||||
|
||||
const fetchAccountDetails = async () => {
|
||||
// For V2 billing, don't call subscription endpoint (it's for V1 Stripe customer creation)
|
||||
// V2 billing accounts are already set up with stripe_billing_version = 2
|
||||
if (isV2Billing.value) {
|
||||
fetchLimits();
|
||||
return;
|
||||
}
|
||||
|
||||
// For V1 billing, create Stripe customer if needed
|
||||
if (!hasABillingPlan.value) {
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
@@ -109,17 +148,106 @@ const handleBillingPageLogic = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const creditBalance = ref(null);
|
||||
const topupUiFlags = computed(() => store.getters['billingV2/uiFlags'] || {});
|
||||
const isTopupProcessing = computed(() =>
|
||||
Boolean(topupUiFlags.value?.isProcessing)
|
||||
);
|
||||
const topupOptions = computed(
|
||||
() => store.getters['billingV2/topupOptions'] || []
|
||||
);
|
||||
|
||||
const formatNumber = value =>
|
||||
new Intl.NumberFormat().format(Number(value || 0));
|
||||
|
||||
const fetchCreditBalance = async () => {
|
||||
if (isV2Billing.value) {
|
||||
try {
|
||||
const response = await store.dispatch('accounts/getCreditBalance');
|
||||
creditBalance.value = response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch credit balance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
|
||||
const fetchTopupOptions = async () => {
|
||||
if (!isV2Billing.value) return;
|
||||
try {
|
||||
await store.dispatch('billingV2/fetchTopupOptions');
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.TOPUP_OPTIONS')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenTopup = async () => {
|
||||
if (!topupOptions.value.length) {
|
||||
await fetchTopupOptions();
|
||||
}
|
||||
isTopupModalOpen.value = true;
|
||||
};
|
||||
|
||||
const onToggleTopupModal = value => {
|
||||
isTopupModalOpen.value = value;
|
||||
};
|
||||
|
||||
const onPurchaseTopup = async credits => {
|
||||
if (!credits || isTopupProcessing.value) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('billingV2/purchaseTopup', { credits });
|
||||
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.SUCCESS', {
|
||||
credits: formatNumber(credits),
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.ERROR', {
|
||||
credits: formatNumber(credits),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
isTopupModalOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleChatWindow = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(handleBillingPageLogic);
|
||||
const onCancelSubscription = async () => {
|
||||
try {
|
||||
const confirmed = await confirmationModal.value.showConfirmation();
|
||||
if (!confirmed) return;
|
||||
|
||||
// Redirect to Stripe billing portal for cancellation
|
||||
await store.dispatch('accounts/cancelSubscription');
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await handleBillingPageLogic();
|
||||
await fetchCreditBalance();
|
||||
await fetchTopupOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,18 +267,75 @@ onMounted(handleBillingPageLogic);
|
||||
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
|
||||
feature-name="billing"
|
||||
/>
|
||||
>
|
||||
<template v-if="isV2Billing" #actions>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-sparkles"
|
||||
@click="
|
||||
$router.push({
|
||||
name: 'billing_settings_v2',
|
||||
params: { accountId: currentAccount.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TITLE') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<section class="grid gap-4">
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||
:description="
|
||||
isV2Billing
|
||||
? 'You are using usage-based billing with AI credits. The billing portal provides invoice history, payment method management, and subscription cancellation. To change your plan, please contact support.'
|
||||
: $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')
|
||||
"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm solid blue @click="onClickBillingPortal">
|
||||
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
<div class="flex gap-2">
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-credit-card"
|
||||
@click="onClickBillingPortal"
|
||||
>
|
||||
{{
|
||||
isV2Billing
|
||||
? $t('BILLING_SETTINGS.V2_BILLING.MANAGE_BILLING')
|
||||
: $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT')
|
||||
}}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
v-if="canCancelSubscription"
|
||||
sm
|
||||
faded
|
||||
red
|
||||
icon="i-lucide-x-circle"
|
||||
@click="onCancelSubscription"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.BUTTON_TXT'
|
||||
)
|
||||
}}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
v-if="isV2Billing"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@click="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.CONTACT_SUPPORT') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
|
||||
@@ -172,6 +357,51 @@ onMounted(handleBillingPageLogic);
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="isV2Billing && creditBalance"
|
||||
:title="$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TITLE')"
|
||||
:description="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.DESCRIPTION')
|
||||
"
|
||||
>
|
||||
<template #action>
|
||||
<div class="flex gap-2">
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
:disabled="isTopupProcessing"
|
||||
:is-loading="isTopupProcessing"
|
||||
@click="onOpenTopup"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
class="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-2 divide-x divide-n-weak"
|
||||
>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.MONTHLY_CREDITS')
|
||||
"
|
||||
:value="creditBalance.monthly_credits || 0"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOPUP_CREDITS')
|
||||
"
|
||||
:value="creditBalance.topup_credits || 0"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOTAL_AVAILABLE')
|
||||
"
|
||||
:value="creditBalance.total_credits || 0"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
<BillingCard
|
||||
v-if="captainEnabled"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
@@ -225,4 +455,20 @@ onMounted(handleBillingPageLogic);
|
||||
</section>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
<ConfirmationModal
|
||||
ref="confirmationModal"
|
||||
:title="$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_TITLE')"
|
||||
description="You will be redirected to the Stripe billing portal where you can cancel your subscription. Your subscription will remain active until the end of the current billing period."
|
||||
confirm-label="Continue to Billing Portal"
|
||||
cancel-label="Cancel"
|
||||
/>
|
||||
|
||||
<BillingTopupModal
|
||||
:model-value="isTopupModalOpen"
|
||||
:options="topupOptions"
|
||||
:is-loading="isTopupProcessing"
|
||||
@update:model-value="onToggleTopupModal"
|
||||
@confirm="onPurchaseTopup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
<script>
|
||||
// Global cache keyed by account ID to survive remounts
|
||||
const accountDataCache = {};
|
||||
|
||||
export default {
|
||||
name: 'V2Billing',
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmationModal from 'dashboard/components/widgets/modal/ConfirmationModal.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import BillingTopupModal from './components/BillingTopupModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
|
||||
const confirmationModal = ref(null);
|
||||
const pricingPlans = ref([]);
|
||||
const creditBalance = ref(null);
|
||||
const subscribingPlanId = ref(null);
|
||||
const isTopupModalOpen = ref(false);
|
||||
const planQuantities = ref({});
|
||||
const isLocallyFetching = ref(true); // Local loading state
|
||||
|
||||
const uiFlags = computed(() => store.getters['accounts/getUIFlags'] || {});
|
||||
const isFetchingPlans = computed(() => Boolean(isLocallyFetching.value));
|
||||
const isUpdating = computed(() => Boolean(uiFlags.value?.isUpdating));
|
||||
const isCheckoutInProcess = computed(() =>
|
||||
Boolean(uiFlags.value?.isCheckoutInProcess)
|
||||
);
|
||||
const topupUiFlags = computed(() => store.getters['billingV2/uiFlags'] || {});
|
||||
const isTopupProcessing = computed(() =>
|
||||
Boolean(topupUiFlags.value.isProcessing)
|
||||
);
|
||||
const topupOptions = computed(
|
||||
() => store.getters['billingV2/topupOptions'] || []
|
||||
);
|
||||
|
||||
const customAttributes = computed(
|
||||
() => currentAccount.value.custom_attributes || {}
|
||||
);
|
||||
const isV2Billing = computed(
|
||||
() => customAttributes.value.stripe_billing_version === 2
|
||||
);
|
||||
const currentPlanId = computed(
|
||||
() => customAttributes.value.stripe_pricing_plan_id
|
||||
);
|
||||
const subscriptionStatus = computed(
|
||||
() => customAttributes.value.subscription_status
|
||||
);
|
||||
const hasActivePlan = computed(() => {
|
||||
// Must have both plan ID and active subscription status
|
||||
return Boolean(currentPlanId.value) && subscriptionStatus.value === 'active';
|
||||
});
|
||||
const canCancelSubscription = computed(
|
||||
() => isV2Billing.value && subscriptionStatus.value === 'active'
|
||||
);
|
||||
|
||||
const planDisplayOrder = ['Hacker', 'Startup', 'Business', 'Enterprise'];
|
||||
const orderedPlans = computed(() => {
|
||||
const plans = Array.isArray(pricingPlans.value)
|
||||
? [...pricingPlans.value]
|
||||
: [];
|
||||
return plans
|
||||
.filter(plan => plan && plan.display_name)
|
||||
.sort((a, b) => {
|
||||
const indexA = planDisplayOrder.indexOf(a.display_name);
|
||||
const indexB = planDisplayOrder.indexOf(b.display_name);
|
||||
|
||||
if (indexA === -1 && indexB === -1) {
|
||||
return a.display_name.localeCompare(b.display_name);
|
||||
}
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
});
|
||||
});
|
||||
|
||||
const findComponent = (components, type) => {
|
||||
if (!Array.isArray(components)) return null;
|
||||
return components.find(component => component.type === type);
|
||||
};
|
||||
|
||||
const extractCreditAmount = components => {
|
||||
const serviceAction = findComponent(components, 'service_action');
|
||||
return Number(serviceAction?.credit_amount) || 0;
|
||||
};
|
||||
|
||||
const extractCreditUnit = components => {
|
||||
const serviceAction = findComponent(components, 'service_action');
|
||||
return (
|
||||
serviceAction?.credit_unit ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.DEFAULT_UNIT')
|
||||
);
|
||||
};
|
||||
|
||||
const extractBaseFee = components => {
|
||||
const licenseFee = findComponent(components, 'license_fee');
|
||||
return Number(licenseFee?.unit_amount) || 0;
|
||||
};
|
||||
|
||||
const planCurrency = plan => plan?.currency || 'usd';
|
||||
|
||||
const formatCurrency = (amount, currency = 'usd') => {
|
||||
const safeAmount = Number.isFinite(amount) ? amount : 0;
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: (currency || 'usd').toUpperCase(),
|
||||
minimumFractionDigits: safeAmount % 1 === 0 ? 0 : 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(safeAmount);
|
||||
};
|
||||
|
||||
const formatNumber = amount => {
|
||||
const safeAmount = Number.isFinite(amount) ? amount : 0;
|
||||
return new Intl.NumberFormat().format(safeAmount);
|
||||
};
|
||||
|
||||
const getPlanSummary = plan => {
|
||||
const baseFee = extractBaseFee(plan.components);
|
||||
const credits = formatNumber(extractCreditAmount(plan.components));
|
||||
const creditUnit = extractCreditUnit(plan.components);
|
||||
|
||||
if (baseFee > 0) {
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.SUMMARY_WITH_BASE', {
|
||||
credits,
|
||||
creditUnit,
|
||||
baseFee: formatCurrency(baseFee, planCurrency(plan)),
|
||||
});
|
||||
}
|
||||
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.SUMMARY_FREE', {
|
||||
credits,
|
||||
creditUnit,
|
||||
});
|
||||
};
|
||||
|
||||
const getPlanHighlights = plan => {
|
||||
const highlights = [];
|
||||
const credits = extractCreditAmount(plan.components);
|
||||
const creditUnit = extractCreditUnit(plan.components);
|
||||
const baseFee = extractBaseFee(plan.components);
|
||||
|
||||
if (credits > 0) {
|
||||
highlights.push(
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_INCLUDED', {
|
||||
credits: formatNumber(credits),
|
||||
unit: creditUnit,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (baseFee > 0) {
|
||||
highlights.push(
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_BASE_FEE', {
|
||||
amount: formatCurrency(baseFee, planCurrency(plan)),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
highlights.push(
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.HIGHLIGHT_NO_BASE_FEE')
|
||||
);
|
||||
}
|
||||
|
||||
return highlights;
|
||||
};
|
||||
|
||||
const planPriceLabel = plan => {
|
||||
const baseFee = extractBaseFee(plan.components);
|
||||
if (baseFee > 0) {
|
||||
return formatCurrency(baseFee, planCurrency(plan));
|
||||
}
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.PRICE_FREE');
|
||||
};
|
||||
|
||||
const baseFeeDetailValue = plan => {
|
||||
const baseFee = extractBaseFee(plan.components);
|
||||
if (baseFee > 0) {
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_VALUE', {
|
||||
amount: formatCurrency(baseFee, planCurrency(plan)),
|
||||
});
|
||||
}
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_VALUE_FREE');
|
||||
};
|
||||
|
||||
const isCurrentPlan = plan => currentPlanId.value === plan.id;
|
||||
|
||||
const actionLabelForPlan = plan => {
|
||||
if (isCurrentPlan(plan)) {
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_CURRENT');
|
||||
}
|
||||
if (hasActivePlan.value) {
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_SWITCH');
|
||||
}
|
||||
return t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.CTA_SELECT');
|
||||
};
|
||||
|
||||
const isPlanLoading = plan => subscribingPlanId.value === plan.id;
|
||||
|
||||
const isPlanActionDisabled = plan => {
|
||||
if (isCurrentPlan(plan)) return true;
|
||||
if (subscribingPlanId.value && subscribingPlanId.value !== plan.id)
|
||||
return true;
|
||||
return isUpdating.value;
|
||||
};
|
||||
|
||||
const getAccountCache = () => {
|
||||
const accountId = currentAccount.value.id;
|
||||
if (!accountDataCache[accountId]) {
|
||||
accountDataCache[accountId] = {
|
||||
dataFetched: false,
|
||||
pricingPlans: [],
|
||||
creditBalance: null,
|
||||
};
|
||||
}
|
||||
return accountDataCache[accountId];
|
||||
};
|
||||
|
||||
const fetchPricingPlans = async () => {
|
||||
try {
|
||||
const response = await store.dispatch('accounts/getV2PricingPlans');
|
||||
const plans = response.pricing_plans || [];
|
||||
const cache = getAccountCache();
|
||||
cache.pricingPlans = plans;
|
||||
pricingPlans.value = plans;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.FETCH_PLANS')
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch pricing plans:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCreditBalance = async () => {
|
||||
if (!isV2Billing.value) return;
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('accounts/getCreditBalance');
|
||||
const cache = getAccountCache();
|
||||
cache.creditBalance = response;
|
||||
creditBalance.value = response;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch credit balance:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenPortal = async () => {
|
||||
try {
|
||||
await store.dispatch('accounts/checkout');
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.PORTAL'));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTopupOptions = async () => {
|
||||
if (!isV2Billing.value) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('billingV2/fetchTopupOptions');
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('BILLING_SETTINGS.V2_BILLING.ERRORS.TOPUP_OPTIONS')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenTopup = async () => {
|
||||
if (!topupOptions.value.length) {
|
||||
await fetchTopupOptions();
|
||||
}
|
||||
isTopupModalOpen.value = true;
|
||||
};
|
||||
|
||||
const onCloseTopup = () => {
|
||||
isTopupModalOpen.value = false;
|
||||
};
|
||||
|
||||
const onPurchaseTopup = async credits => {
|
||||
if (!credits || isTopupProcessing.value) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('billingV2/purchaseTopup', { credits });
|
||||
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.SUCCESS', {
|
||||
credits: formatNumber(credits),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.TOPUP.ERROR', {
|
||||
credits: formatNumber(credits),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
isTopupModalOpen.value = false;
|
||||
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleTopupModal = value => {
|
||||
isTopupModalOpen.value = value;
|
||||
};
|
||||
|
||||
const getPlanQuantity = plan => {
|
||||
return planQuantities.value[plan.id] || 1;
|
||||
};
|
||||
|
||||
const setPlanQuantity = (plan, quantity) => {
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (qty > 0 && qty <= 100) {
|
||||
planQuantities.value[plan.id] = qty;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectPlan = async plan => {
|
||||
if (!plan || isPlanActionDisabled(plan)) return;
|
||||
|
||||
subscribingPlanId.value = plan.id;
|
||||
const quantity = getPlanQuantity(plan);
|
||||
|
||||
try {
|
||||
// Use v2_subscribe for both new subscriptions and plan changes
|
||||
const response = await store.dispatch('accounts/subscribeToV2Plan', {
|
||||
pricing_plan_id: plan.id,
|
||||
quantity: quantity,
|
||||
});
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
if (response.redirect_url) {
|
||||
window.location.href = response.redirect_url;
|
||||
} else if (response.success) {
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_UPDATE.SUCCESS', {
|
||||
plan: plan.display_name,
|
||||
})
|
||||
);
|
||||
await Promise.all([store.dispatch('accounts/get'), fetchCreditBalance()]);
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.PLAN_UPDATE.ERROR', {
|
||||
plan: plan.display_name,
|
||||
})
|
||||
);
|
||||
subscribingPlanId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onCancelSubscription = async () => {
|
||||
try {
|
||||
const confirmed = await confirmationModal.value.showConfirmation();
|
||||
if (!confirmed) return;
|
||||
|
||||
// Cancel at period end - subscription remains active until billing period ends
|
||||
const response = await store.dispatch('accounts/cancelSubscription', {});
|
||||
|
||||
if (response.period_end) {
|
||||
const periodEndDate = new Date(response.period_end).toLocaleDateString();
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.SUCCESS_WITH_DATE', {
|
||||
date: periodEndDate,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
useAlert(t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.SUCCESS'));
|
||||
}
|
||||
|
||||
await store.dispatch('accounts/get');
|
||||
await fetchCreditBalance();
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onBackToBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: currentAccount.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// Set loading to false immediately for testing
|
||||
isLocallyFetching.value = false;
|
||||
|
||||
// TODO: Temporarily disabled for local testing
|
||||
// if (!isOnChatwootCloud.value) {
|
||||
// router.push({ name: 'home' });
|
||||
// return;
|
||||
// }
|
||||
|
||||
try {
|
||||
// Always refresh account data to ensure we have latest billing info with timeout
|
||||
const accountTimeout = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Account fetch timeout')), 5000)
|
||||
);
|
||||
await Promise.race([store.dispatch('accounts/get'), accountTimeout]);
|
||||
|
||||
// Handle return from Stripe Checkout
|
||||
const purchaseStatus = router.currentRoute.value.query.purchase;
|
||||
if (purchaseStatus === 'success') {
|
||||
useAlert(t('BILLING_SETTINGS.V2_BILLING.CHECKOUT_SUCCESS'));
|
||||
// Remove query parameter
|
||||
router.replace({
|
||||
name: router.currentRoute.value.name,
|
||||
params: router.currentRoute.value.params,
|
||||
});
|
||||
} else if (purchaseStatus === 'cancelled') {
|
||||
useAlert(t('BILLING_SETTINGS.V2_BILLING.CHECKOUT_CANCELLED'));
|
||||
// Remove query parameter
|
||||
router.replace({
|
||||
name: router.currentRoute.value.name,
|
||||
params: router.currentRoute.value.params,
|
||||
});
|
||||
}
|
||||
|
||||
const cache = getAccountCache();
|
||||
const shouldFetchPlans = !cache.dataFetched || !cache.pricingPlans?.length;
|
||||
|
||||
if (shouldFetchPlans) {
|
||||
cache.dataFetched = true;
|
||||
try {
|
||||
// Add timeout to prevent indefinite hanging
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Request timeout')), 10000)
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.all([
|
||||
fetchPricingPlans(),
|
||||
fetchCreditBalance(),
|
||||
fetchTopupOptions(),
|
||||
]),
|
||||
timeoutPromise
|
||||
]);
|
||||
} catch (error) {
|
||||
cache.dataFetched = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch billing data:', error);
|
||||
}
|
||||
} else {
|
||||
pricingPlans.value = cache.pricingPlans;
|
||||
creditBalance.value = cache.creditBalance;
|
||||
await fetchTopupOptions();
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error in onMounted:', error);
|
||||
} finally {
|
||||
// Always set loading to false after mount completes
|
||||
isLocallyFetching.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('BILLING_SETTINGS.V2_BILLING.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.V2_BILLING.DESCRIPTION')"
|
||||
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
|
||||
feature-name="billing"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<section class="space-y-6">
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<ButtonV4
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-arrow-left"
|
||||
@click="onBackToBilling"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.BACK_TO_BILLING') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-credit-card"
|
||||
:is-loading="isCheckoutInProcess"
|
||||
:disabled="isCheckoutInProcess"
|
||||
@click="onOpenPortal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.MANAGE_PAYMENT_METHOD') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
|
||||
<BillingCard
|
||||
v-if="isV2Billing"
|
||||
:title="$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TITLE')"
|
||||
:description="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.DESCRIPTION')
|
||||
"
|
||||
>
|
||||
<div v-if="isFetchingPlans" class="flex justify-center py-8">
|
||||
<Spinner :size="24" class="text-n-brand" />
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-if="hasActivePlan"
|
||||
class="grid grid-cols-1 gap-2 divide-y divide-n-weak border-b border-n-weak pb-4 sm:grid-cols-2 sm:divide-y-0 sm:divide-x"
|
||||
>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.PLAN_NAME')
|
||||
"
|
||||
:value="customAttributes.plan_name || 'No active plan'"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.SUBSCRIBED_SEATS'
|
||||
)
|
||||
"
|
||||
:value="formatNumber(customAttributes.subscribed_quantity || 1)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 divide-y divide-n-weak sm:grid-cols-3 sm:divide-y-0 sm:divide-x"
|
||||
>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.MONTHLY_CREDITS'
|
||||
)
|
||||
"
|
||||
:value="formatNumber(creditBalance?.monthly_credits || 0)"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOPUP_CREDITS')
|
||||
"
|
||||
:value="formatNumber(creditBalance?.topup_credits || 0)"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.TOTAL_AVAILABLE'
|
||||
)
|
||||
"
|
||||
:value="formatNumber(creditBalance?.total_credits || 0)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="creditBalance?.usage_this_month !== undefined || creditBalance?.usage_total !== undefined"
|
||||
class="border-t border-n-weak pt-4"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 divide-y divide-n-weak sm:grid-cols-2 sm:divide-y-0 sm:divide-x"
|
||||
>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.USAGE_THIS_MONTH'
|
||||
)
|
||||
"
|
||||
:value="formatNumber(creditBalance?.usage_this_month || 0)"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CREDIT_BALANCE.USAGE_TOTAL')
|
||||
"
|
||||
:value="formatNumber(creditBalance?.usage_total || 0)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #action>
|
||||
<div class="flex gap-2">
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
:disabled="isTopupProcessing"
|
||||
:is-loading="isTopupProcessing"
|
||||
@click="onOpenTopup"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.BUTTON') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
v-if="canCancelSubscription"
|
||||
sm
|
||||
faded
|
||||
red
|
||||
icon="i-lucide-x-circle"
|
||||
:disabled="isUpdating"
|
||||
:is-loading="isUpdating && !subscribingPlanId"
|
||||
@click="onCancelSubscription"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.BUTTON_TXT'
|
||||
)
|
||||
}}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
</BillingCard>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div
|
||||
class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-n-900 dark:text-n-100">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.PLAN_SECTION.HELP_TEXT') }}
|
||||
</p>
|
||||
|
||||
<div v-if="isFetchingPlans" class="flex justify-center py-16">
|
||||
<Spinner :size="32" class="text-n-brand" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!orderedPlans.length"
|
||||
class="rounded-2xl border border-dashed border-n-weak bg-n-solid-1 px-6 py-10 text-center text-sm text-n-slate-11"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.NO_PLANS_AVAILABLE') }}
|
||||
</div>
|
||||
<div v-else class="grid gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="plan in orderedPlans"
|
||||
:key="plan.id"
|
||||
class="transition-all duration-200"
|
||||
:class="[
|
||||
isCurrentPlan(plan)
|
||||
? 'rounded-2xl ring-2 ring-n-brand shadow-lg'
|
||||
: 'rounded-2xl hover:shadow-md',
|
||||
]"
|
||||
>
|
||||
<BillingCard
|
||||
:title="plan.display_name"
|
||||
:description="getPlanSummary(plan)"
|
||||
>
|
||||
<template #action>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div
|
||||
v-if="isCurrentPlan(plan)"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-1.5 text-sm font-medium text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300"
|
||||
>
|
||||
<span class="i-lucide-check-circle text-base" />
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.CURRENT_PLAN') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-sm font-medium text-n-slate-11"
|
||||
:for="`seats-${plan.id}`"
|
||||
>
|
||||
Seats:
|
||||
</label>
|
||||
<input
|
||||
:id="`seats-${plan.id}`"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="getPlanQuantity(plan)"
|
||||
class="w-20 rounded-lg border border-n-weak bg-n-solid-1 px-3 py-1.5 text-sm text-n-slate-12 focus:border-n-brand focus:outline-none focus:ring-1 focus:ring-n-brand"
|
||||
@input="e => setPlanQuantity(plan, e.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
icon="i-lucide-shopping-cart"
|
||||
:disabled="isPlanActionDisabled(plan)"
|
||||
:is-loading="isPlanLoading(plan)"
|
||||
@click="onSelectPlan(plan)"
|
||||
>
|
||||
{{ actionLabelForPlan(plan) }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5 px-5 pb-5">
|
||||
<div class="flex flex-wrap items-baseline gap-2">
|
||||
<span class="text-3xl font-semibold text-n-slate-12">
|
||||
{{ planPriceLabel(plan) }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('BILLING_SETTINGS.V2_BILLING.PLAN_CARD.PER_MONTH')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-n-slate-12">
|
||||
<li
|
||||
v-for="(highlight, index) in getPlanHighlights(plan)"
|
||||
:key="`${plan.id}-highlight-${index}`"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span
|
||||
class="i-lucide-check mt-0.5 text-base text-n-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="leading-5">
|
||||
{{ highlight }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="grid gap-2 border-t border-n-weak pt-4 sm:grid-cols-2"
|
||||
>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.PLAN_CARD.BASE_FEE_LABEL'
|
||||
)
|
||||
"
|
||||
:value="baseFeeDetailValue(plan)"
|
||||
/>
|
||||
<DetailItem
|
||||
:label="
|
||||
$t(
|
||||
'BILLING_SETTINGS.V2_BILLING.PLAN_CARD.MONTHLY_CREDITS_LABEL'
|
||||
)
|
||||
"
|
||||
:value="
|
||||
formatNumber(extractCreditAmount(plan.components))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BillingCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
<ConfirmationModal
|
||||
ref="confirmationModal"
|
||||
:title="$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_TITLE')"
|
||||
:description="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_DESCRIPTION')
|
||||
"
|
||||
:confirm-label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CONFIRM_LABEL')
|
||||
"
|
||||
:cancel-label="
|
||||
$t('BILLING_SETTINGS.V2_BILLING.CANCEL_SUBSCRIPTION.CANCEL_LABEL')
|
||||
"
|
||||
/>
|
||||
|
||||
<BillingTopupModal
|
||||
:model-value="isTopupModalOpen"
|
||||
:options="topupOptions"
|
||||
:is-loading="isTopupProcessing"
|
||||
@update:model-value="onToggleTopupModal"
|
||||
@confirm="onPurchaseTopup"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@ import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
import V2Billing from './V2Billing.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -27,6 +28,15 @@ export default {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'v2',
|
||||
name: 'billing_settings_v2',
|
||||
component: V2Billing,
|
||||
meta: {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD],
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm']);
|
||||
const { t } = useI18n();
|
||||
const selectedCredits = ref(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
if (!value) {
|
||||
selectedCredits.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
options => {
|
||||
if (
|
||||
props.modelValue &&
|
||||
(!selectedCredits.value ||
|
||||
!options?.some(opt => opt.credits === selectedCredits.value))
|
||||
) {
|
||||
selectedCredits.value = options?.[0]?.credits || null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const formattedOptions = computed(() => {
|
||||
return (props.options || []).map(option => ({
|
||||
...option,
|
||||
label: `${option.credits.toLocaleString()} ${t(
|
||||
'BILLING_SETTINGS.V2_BILLING.TOPUP.CREDITS_LABEL'
|
||||
)}`,
|
||||
price: new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: (option.currency || 'usd').toUpperCase(),
|
||||
minimumFractionDigits: 2,
|
||||
}).format(option.amount),
|
||||
}));
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!selectedCredits.value) return;
|
||||
emit('confirm', selectedCredits.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="modelValue"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-n-alpha-7 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-2xl bg-n-solid-1 shadow-xl">
|
||||
<header class="px-6 pt-6 pb-4 border-b border-n-weak">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-n-slate-11">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.DESCRIPTION') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="px-6 py-4 space-y-4">
|
||||
<div class="grid gap-3">
|
||||
<label
|
||||
v-for="option in formattedOptions"
|
||||
:key="option.credits"
|
||||
class="flex cursor-pointer items-center justify-between rounded-xl border px-4 py-3 transition-colors"
|
||||
:class="
|
||||
selectedCredits === option.credits
|
||||
? 'border-n-brand bg-n-brand/5'
|
||||
: 'border-n-weak hover:border-n-brand/60'
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-10">
|
||||
{{
|
||||
$t('BILLING_SETTINGS.V2_BILLING.TOPUP.INCLUDES', {
|
||||
amount: option.price,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="selectedCredits"
|
||||
type="radio"
|
||||
class="form-radio h-4 w-4 text-n-brand"
|
||||
:value="option.credits"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
class="flex items-center justify-end gap-3 border-t border-n-weak px-6 py-4"
|
||||
>
|
||||
<ButtonV4 sm faded slate @click="onClose">
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.CANCEL') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!selectedCredits"
|
||||
:is-loading="isLoading"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.V2_BILLING.TOPUP.CONFIRM') }}
|
||||
</ButtonV4>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
+1
-1
@@ -5,7 +5,7 @@ defineProps({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import ReportHeader from './components/ReportHeader.vue';
|
||||
import HeatmapContainer from './components/HeatmapContainer.vue';
|
||||
import ConversationHeatmapContainer from './components/heatmaps/ConversationHeatmapContainer.vue';
|
||||
import ResolutionHeatmapContainer from './components/heatmaps/ResolutionHeatmapContainer.vue';
|
||||
import AgentLiveReportContainer from './components/AgentLiveReportContainer.vue';
|
||||
import TeamLiveReportContainer from './components/TeamLiveReportContainer.vue';
|
||||
import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vue';
|
||||
@@ -10,7 +11,8 @@ import StatsLiveReportsContainer from './components/StatsLiveReportsContainer.vu
|
||||
<ReportHeader :header-title="$t('OVERVIEW_REPORTS.HEADER')" />
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<StatsLiveReportsContainer />
|
||||
<HeatmapContainer />
|
||||
<ConversationHeatmapContainer />
|
||||
<ResolutionHeatmapContainer />
|
||||
<AgentLiveReportContainer />
|
||||
<TeamLiveReportContainer />
|
||||
</div>
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import format from 'date-fns/format';
|
||||
import getDay from 'date-fns/getDay';
|
||||
|
||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||
|
||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
numberOfRows: {
|
||||
type: Number,
|
||||
default: 7,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
const processedData = computed(() => {
|
||||
return groupHeatmapByDay(props.heatmapData);
|
||||
});
|
||||
|
||||
const quantileRange = computed(() => {
|
||||
const flattendedData = props.heatmapData.map(data => data.value);
|
||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||
});
|
||||
|
||||
function getCountTooltip(value) {
|
||||
if (!value) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
||||
}
|
||||
|
||||
if (value === 1) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||
count: value,
|
||||
});
|
||||
}
|
||||
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||
count: value,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
function getDayOfTheWeek(date) {
|
||||
const dayIndex = getDay(date);
|
||||
const days = [
|
||||
t('DAYS_OF_WEEK.SUNDAY'),
|
||||
t('DAYS_OF_WEEK.MONDAY'),
|
||||
t('DAYS_OF_WEEK.TUESDAY'),
|
||||
t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||
t('DAYS_OF_WEEK.THURSDAY'),
|
||||
t('DAYS_OF_WEEK.FRIDAY'),
|
||||
t('DAYS_OF_WEEK.SATURDAY'),
|
||||
];
|
||||
return days[dayIndex];
|
||||
}
|
||||
function getHeatmapLevelClass(value) {
|
||||
if (!value) return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
|
||||
|
||||
let level = [...quantileRange.value, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
);
|
||||
|
||||
if (level > 6) level = 5;
|
||||
|
||||
if (level === 0) {
|
||||
return 'outline-n-container bg-n-slate-2 dark:bg-n-slate-5/50';
|
||||
}
|
||||
|
||||
const classes = [
|
||||
'bg-n-blue-3 dark:outline-n-blue-4',
|
||||
'bg-n-blue-5 dark:outline-n-blue-6',
|
||||
'bg-n-blue-7 dark:outline-n-blue-8',
|
||||
'bg-n-blue-8 dark:outline-n-blue-9',
|
||||
'bg-n-blue-10 dark:outline-n-blue-8',
|
||||
'bg-n-blue-11 dark:outline-n-blue-10',
|
||||
];
|
||||
|
||||
return classes[level - 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
<div
|
||||
v-for="jj in 24"
|
||||
:key="jj"
|
||||
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }} – {{ ii }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="dateKey in processedData.keys()"
|
||||
:key="dateKey"
|
||||
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
|
||||
>
|
||||
{{ getDayOfTheWeek(new Date(dateKey)) }}
|
||||
<time class="font-normal text-n-slate-11">
|
||||
{{ formatDate(dateKey) }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="dateKey in processedData.keys()"
|
||||
:key="dateKey"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
<div
|
||||
v-for="data in processedData.get(dateKey)"
|
||||
:key="data.timestamp"
|
||||
v-tooltip.top="getCountTooltip(data.value)"
|
||||
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1"
|
||||
:class="getHeatmapLevelClass(data.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }} – {{ ii }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import MetricCard from './overview/MetricCard.vue';
|
||||
import ReportHeatmap from './Heatmap.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||
const accountConversationHeatmap = useMapGetter(
|
||||
'getAccountConversationHeatmapData'
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||
value: 29,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedDays = ref(6);
|
||||
|
||||
const selectedDayFilter = computed(() =>
|
||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||
);
|
||||
|
||||
const downloadHeatmapData = () => {
|
||||
const to = endOfDay(new Date());
|
||||
store.dispatch('downloadAccountConversationHeatmap', {
|
||||
daysBefore: selectedDays.value,
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
};
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const fetchHeatmapData = () => {
|
||||
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||
return;
|
||||
}
|
||||
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||
|
||||
store.dispatch('fetchAccountConversationHeatmap', {
|
||||
metric: 'conversations_count',
|
||||
from: getUnixTime(from),
|
||||
to: getUnixTime(to),
|
||||
groupBy: 'hour',
|
||||
businessHours: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
selectedDays.value = value;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||
|
||||
onMounted(() => {
|
||||
fetchHeatmapData();
|
||||
startRefetching();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||
<template #control>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedDayFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="downloadHeatmapData"
|
||||
/>
|
||||
</template>
|
||||
<ReportHeatmap
|
||||
:heatmap-data="accountConversationHeatmap"
|
||||
:number-of-rows="selectedDays + 1"
|
||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</template>
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMemoize } from '@vueuse/core';
|
||||
|
||||
import format from 'date-fns/format';
|
||||
import getDay from 'date-fns/getDay';
|
||||
|
||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||
|
||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useHeatmapTooltip } from './composables/useHeatmapTooltip';
|
||||
import HeatmapTooltip from './HeatmapTooltip.vue';
|
||||
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
numberOfRows: {
|
||||
type: Number,
|
||||
default: 7,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
validator: value => ['blue', 'green'].includes(value),
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const dataRows = computed(() => {
|
||||
const groupedData = groupHeatmapByDay(props.heatmapData);
|
||||
return Array.from(groupedData.keys()).map(dateKey => {
|
||||
const rowData = groupedData.get(dateKey);
|
||||
return {
|
||||
dateKey,
|
||||
data: rowData,
|
||||
dataHash: rowData.map(d => d.value).join(','),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const quantileRange = computed(() => {
|
||||
const flattendedData = props.heatmapData.map(data => data.value);
|
||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
t('DAYS_OF_WEEK.SUNDAY'),
|
||||
t('DAYS_OF_WEEK.MONDAY'),
|
||||
t('DAYS_OF_WEEK.TUESDAY'),
|
||||
t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||
t('DAYS_OF_WEEK.THURSDAY'),
|
||||
t('DAYS_OF_WEEK.FRIDAY'),
|
||||
t('DAYS_OF_WEEK.SATURDAY'),
|
||||
];
|
||||
|
||||
function getDayOfTheWeek(date) {
|
||||
const dayIndex = getDay(date);
|
||||
|
||||
return DAYS_OF_WEEK[dayIndex];
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES = {
|
||||
blue: [
|
||||
'bg-n-blue-3 border border-n-blue-4/30',
|
||||
'bg-n-blue-5 border border-n-blue-6/30',
|
||||
'bg-n-blue-7 border border-n-blue-8/30',
|
||||
'bg-n-blue-8 border border-n-blue-9/30',
|
||||
'bg-n-blue-10 border border-n-blue-8/30',
|
||||
'bg-n-blue-11 border border-n-blue-10/30',
|
||||
],
|
||||
green: [
|
||||
'bg-n-teal-3 border border-n-teal-4/30',
|
||||
'bg-n-teal-5 border border-n-teal-6/30',
|
||||
'bg-n-teal-7 border border-n-teal-8/30',
|
||||
'bg-n-teal-8 border border-n-teal-9/30',
|
||||
'bg-n-teal-10 border border-n-teal-8/30',
|
||||
'bg-n-teal-11 border border-n-teal-10/30',
|
||||
],
|
||||
};
|
||||
|
||||
// Memoized function to calculate CSS class for heatmap cell intensity levels
|
||||
const getHeatmapLevelClass = useMemoize(
|
||||
(value, quantileRangeArray, colorScheme) => {
|
||||
if (!value)
|
||||
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
|
||||
let level = [...quantileRangeArray, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
);
|
||||
|
||||
if (level > 6) level = 5;
|
||||
|
||||
if (level === 0) {
|
||||
return 'border border-n-container bg-n-slate-2 dark:bg-n-slate-1/30';
|
||||
}
|
||||
|
||||
return COLOR_SCHEMES[colorScheme][level - 1];
|
||||
}
|
||||
);
|
||||
|
||||
function getHeatmapClass(value) {
|
||||
return getHeatmapLevelClass(value, quantileRange.value, props.colorScheme);
|
||||
}
|
||||
|
||||
// Tooltip composable
|
||||
const tooltip = useHeatmapTooltip();
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div
|
||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="w-full rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse h-8 min-w-[70px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="ii in numberOfRows"
|
||||
:key="ii"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
<div
|
||||
v-for="jj in 24"
|
||||
:key="jj"
|
||||
class="w-full h-8 rounded-sm bg-n-slate-3 dark:bg-n-slate-1 animate-loader-pulse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-11"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="row in dataRows"
|
||||
:key="row.dateKey"
|
||||
v-memo="[row.dateKey]"
|
||||
class="h-8 min-w-[70px] text-n-slate-12 text-[10px] font-semibold flex flex-col items-end justify-center"
|
||||
>
|
||||
{{ getDayOfTheWeek(new Date(row.dateKey)) }}
|
||||
<time class="font-normal text-n-slate-11">
|
||||
{{ formatDate(row.dateKey) }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-[5px] w-full min-w-[700px]"
|
||||
style="content-visibility: auto"
|
||||
>
|
||||
<div
|
||||
v-for="row in dataRows"
|
||||
:key="row.dateKey"
|
||||
v-memo="[row.dataHash, colorScheme]"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
style="content-visibility: auto"
|
||||
>
|
||||
<div
|
||||
v-for="data in row.data"
|
||||
:key="data.timestamp"
|
||||
class="h-8 rounded-sm cursor-pointer"
|
||||
:class="getHeatmapClass(data.value)"
|
||||
@mouseenter="tooltip.show($event, data.value)"
|
||||
@mouseleave="tooltip.hide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-n-slate-12"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<HeatmapTooltip
|
||||
:visible="tooltip.visible.value"
|
||||
:x="tooltip.x.value"
|
||||
:y="tooltip.y.value"
|
||||
:value="tooltip.value.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import MetricCard from '../overview/MetricCard.vue';
|
||||
import BaseHeatmap from './BaseHeatmap.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import subDays from 'date-fns/subDays';
|
||||
import format from 'date-fns/format';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
|
||||
|
||||
const props = defineProps({
|
||||
metric: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
downloadTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
storeGetter: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
storeAction: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
downloadAction: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
uiFlagKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: 'blue',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||
const heatmapData = useMapGetter(props.storeGetter);
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
|
||||
value: 13,
|
||||
},
|
||||
{
|
||||
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||
value: 29,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedDays = ref(6);
|
||||
const selectedInbox = ref(null);
|
||||
|
||||
const selectedDayFilter = computed(() =>
|
||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||
);
|
||||
|
||||
const inboxMenuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('INBOX_REPORTS.ALL_INBOXES'),
|
||||
value: null,
|
||||
action: 'select_inbox',
|
||||
},
|
||||
...inboxes.value.map(inbox => ({
|
||||
label: inbox.name,
|
||||
value: inbox.id,
|
||||
action: 'select_inbox',
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedInboxFilter = computed(() => {
|
||||
if (!selectedInbox.value) {
|
||||
return { label: t('INBOX_REPORTS.ALL_INBOXES') };
|
||||
}
|
||||
return inboxMenuItems.value.find(
|
||||
item => item.value === selectedInbox.value.id
|
||||
);
|
||||
});
|
||||
|
||||
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
|
||||
|
||||
const downloadHeatmapData = () => {
|
||||
const to = endOfDay(new Date());
|
||||
|
||||
// If no inbox is selected and download action exists, use backend endpoint
|
||||
if (!selectedInbox.value && props.downloadAction) {
|
||||
store.dispatch(props.downloadAction, {
|
||||
daysBefore: selectedDays.value,
|
||||
to: getUnixTime(to),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate CSV from store data
|
||||
if (!heatmapData.value || heatmapData.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV headers
|
||||
const headers = ['Date', 'Hour', props.title];
|
||||
const rows = [headers];
|
||||
|
||||
// Convert heatmap data to rows
|
||||
heatmapData.value.forEach(item => {
|
||||
const date = new Date(item.timestamp * 1000);
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
const hour = date.getHours();
|
||||
rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]);
|
||||
});
|
||||
|
||||
// Convert to CSV string
|
||||
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||
|
||||
// Generate filename
|
||||
const inboxName = selectedInbox.value
|
||||
? `_${selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_')}`
|
||||
: '';
|
||||
const fileName = `${props.downloadTitle}${inboxName}_${format(
|
||||
new Date(),
|
||||
'dd-MM-yyyy'
|
||||
)}.csv`;
|
||||
|
||||
// Download the file
|
||||
downloadCsvFile(fileName, csvContent);
|
||||
};
|
||||
|
||||
const [showDropdown, toggleDropdown] = useToggle();
|
||||
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||
|
||||
const fetchHeatmapData = () => {
|
||||
if (isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let to = endOfDay(new Date());
|
||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||
|
||||
const params = {
|
||||
metric: props.metric,
|
||||
from: getUnixTime(from),
|
||||
to: getUnixTime(to),
|
||||
groupBy: 'hour',
|
||||
businessHours: false,
|
||||
};
|
||||
|
||||
// Add inbox filtering if an inbox is selected
|
||||
if (selectedInbox.value) {
|
||||
params.type = 'inbox';
|
||||
params.id = selectedInbox.value.id;
|
||||
}
|
||||
|
||||
store.dispatch(props.storeAction, params);
|
||||
};
|
||||
|
||||
const handleAction = ({ value }) => {
|
||||
toggleDropdown(false);
|
||||
selectedDays.value = value;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value }) => {
|
||||
toggleInboxDropdown(false);
|
||||
selectedInbox.value = value
|
||||
? inboxes.value.find(inbox => inbox.id === value)
|
||||
: null;
|
||||
fetchHeatmapData();
|
||||
};
|
||||
|
||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
fetchHeatmapData();
|
||||
startRefetching();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row flex-wrap max-w-full">
|
||||
<MetricCard :header="title">
|
||||
<template #control>
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedDayFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="toggleDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||
class="relative flex items-center group"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="selectedInboxFilter.label"
|
||||
class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]"
|
||||
@click="toggleInboxDropdown()"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showInboxDropdown"
|
||||
:menu-items="inboxMenuItems"
|
||||
show-search
|
||||
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
|
||||
@action="handleInboxAction($event)"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
sm
|
||||
slate
|
||||
faded
|
||||
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||
class="rounded-md group-hover:bg-n-alpha-2"
|
||||
@click="downloadHeatmapData"
|
||||
/>
|
||||
</template>
|
||||
<BaseHeatmap
|
||||
:heatmap-data="heatmapData"
|
||||
:number-of-rows="selectedDays + 1"
|
||||
:is-loading="isLoading"
|
||||
:color-scheme="colorScheme"
|
||||
/>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</template>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseHeatmapContainer
|
||||
metric="conversations_count"
|
||||
:title="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
|
||||
download-title="conversation_heatmap"
|
||||
store-getter="getAccountConversationHeatmapData"
|
||||
store-action="fetchAccountConversationHeatmap"
|
||||
download-action="downloadAccountConversationHeatmap"
|
||||
ui-flag-key="isFetchingAccountConversationsHeatmap"
|
||||
/>
|
||||
</template>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (!props.value) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
||||
}
|
||||
|
||||
if (props.value === 1) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||
count: props.value,
|
||||
});
|
||||
}
|
||||
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||
count: props.value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<div
|
||||
class="fixed z-50 px-2 py-1 text-xs font-medium text-n-slate-6 bg-n-slate-12 rounded shadow-lg pointer-events-none transition-[opacity,transform] duration-75"
|
||||
:class="{ 'opacity-100': visible, 'opacity-0': !visible }"
|
||||
:style="{
|
||||
left: `${x}px`,
|
||||
top: `${y - 15}px`,
|
||||
transform: 'translateX(-50%) translateZ(0)',
|
||||
willChange: 'transform, opacity',
|
||||
}"
|
||||
>
|
||||
{{ tooltipText }}
|
||||
</div>
|
||||
</template>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import BaseHeatmapContainer from './BaseHeatmapContainer.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseHeatmapContainer
|
||||
metric="resolutions_count"
|
||||
:title="t('OVERVIEW_REPORTS.RESOLUTION_HEATMAP.HEADER')"
|
||||
download-title="resolution_heatmap"
|
||||
store-getter="getAccountResolutionHeatmapData"
|
||||
store-action="fetchAccountResolutionHeatmap"
|
||||
ui-flag-key="isFetchingAccountResolutionsHeatmap"
|
||||
color-scheme="green"
|
||||
/>
|
||||
</template>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useHeatmapTooltip() {
|
||||
const visible = ref(false);
|
||||
const x = ref(0);
|
||||
const y = ref(0);
|
||||
const value = ref(null);
|
||||
|
||||
let timeoutId = null;
|
||||
|
||||
const show = (event, cellValue) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Update position immediately for smooth movement
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
x.value = rect.left + rect.width / 2;
|
||||
y.value = rect.top;
|
||||
|
||||
// Only delay content update and visibility
|
||||
timeoutId = setTimeout(() => {
|
||||
value.value = cellValue;
|
||||
visible.value = true;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
return { visible, x, y, value, show, hide };
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import teamMembers from './modules/teamMembers';
|
||||
import teams from './modules/teams';
|
||||
import userNotificationSettings from './modules/userNotificationSettings';
|
||||
import webhooks from './modules/webhooks';
|
||||
import billingV2 from './modules/billingV2';
|
||||
import captainAssistants from './captain/assistant';
|
||||
import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
@@ -111,6 +112,7 @@ export default createStore({
|
||||
teams,
|
||||
userNotificationSettings,
|
||||
webhooks,
|
||||
billingV2,
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
|
||||
@@ -149,6 +149,53 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
getCreditBalance: async () => {
|
||||
const response = await EnterpriseAccountAPI.getCreditBalance();
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getV2PricingPlans: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getV2PricingPlans();
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
subscribeToV2Plan: async ({ commit }, { pricing_plan_id, quantity = 1 }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.subscribeToV2Plan(
|
||||
pricing_plan_id,
|
||||
quantity
|
||||
);
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
cancelSubscription: async ({ commit }, options = {}) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.cancelSubscription(options);
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
getCacheKeys: async () => {
|
||||
return AccountAPI.getCacheKeys();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
|
||||
const state = {
|
||||
topupOptions: [],
|
||||
uiFlags: {
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
topupOptions($state) {
|
||||
return $state.topupOptions;
|
||||
},
|
||||
uiFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
async fetchTopupOptions({ commit }) {
|
||||
commit('SET_UI_FLAG', { isLoading: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getV2TopupOptions();
|
||||
commit('SET_TOPUP_OPTIONS', response.data.topup_options);
|
||||
return response.data.topup_options;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
async purchaseTopup({ commit }, { credits }) {
|
||||
commit('SET_UI_FLAG', { isProcessing: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.purchaseTopup(credits);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isProcessing: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
SET_UI_FLAG($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
SET_TOPUP_OPTIONS($state, options) {
|
||||
$state.topupOptions = options || [];
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -26,12 +26,12 @@ const fetchMetaData = async (commit, params) => {
|
||||
};
|
||||
|
||||
const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1500);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 1000, false, 8000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
|
||||
const superLongDebouncedFetchMetaData = debounce(
|
||||
fetchMetaData,
|
||||
1500,
|
||||
10000,
|
||||
false,
|
||||
10000
|
||||
20000
|
||||
);
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -57,11 +57,13 @@ const state = {
|
||||
uiFlags: {
|
||||
isFetchingAccountConversationMetric: false,
|
||||
isFetchingAccountConversationsHeatmap: false,
|
||||
isFetchingAccountResolutionsHeatmap: false,
|
||||
isFetchingAgentConversationMetric: false,
|
||||
isFetchingTeamConversationMetric: false,
|
||||
},
|
||||
accountConversationMetric: {},
|
||||
accountConversationHeatmap: [],
|
||||
accountResolutionHeatmap: [],
|
||||
agentConversationMetric: [],
|
||||
teamConversationMetric: [],
|
||||
},
|
||||
@@ -89,6 +91,9 @@ const getters = {
|
||||
getAccountConversationHeatmapData(_state) {
|
||||
return _state.overview.accountConversationHeatmap;
|
||||
},
|
||||
getAccountResolutionHeatmapData(_state) {
|
||||
return _state.overview.accountResolutionHeatmap;
|
||||
},
|
||||
getAgentConversationMetric(_state) {
|
||||
return _state.overview.agentConversationMetric;
|
||||
},
|
||||
@@ -130,6 +135,16 @@ export const actions = {
|
||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchAccountResolutionHeatmap({ commit }, reportObj) {
|
||||
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
|
||||
Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
|
||||
let { data } = heatmapData;
|
||||
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||
|
||||
commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
|
||||
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
|
||||
});
|
||||
},
|
||||
fetchAccountSummary({ commit }, reportObj) {
|
||||
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
|
||||
Report.getSummary(
|
||||
@@ -287,6 +302,9 @@ const mutations = {
|
||||
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
|
||||
_state.overview.accountConversationHeatmap = heatmapData;
|
||||
},
|
||||
[types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
|
||||
_state.overview.accountResolutionHeatmap = heatmapData;
|
||||
},
|
||||
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
|
||||
_state.accountReport.isFetching[metric] = value;
|
||||
},
|
||||
@@ -299,6 +317,9 @@ const mutations = {
|
||||
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
|
||||
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
|
||||
},
|
||||
[types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
|
||||
_state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
|
||||
},
|
||||
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
|
||||
_state.accountSummary = summaryData;
|
||||
},
|
||||
|
||||
@@ -187,6 +187,8 @@ export default {
|
||||
SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS',
|
||||
SET_HEATMAP_DATA: 'SET_HEATMAP_DATA',
|
||||
TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING',
|
||||
SET_RESOLUTION_HEATMAP_DATA: 'SET_RESOLUTION_HEATMAP_DATA',
|
||||
TOGGLE_RESOLUTION_HEATMAP_LOADING: 'TOGGLE_RESOLUTION_HEATMAP_LOADING',
|
||||
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
|
||||
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
|
||||
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
class AgentBots::WebhookJob < WebhookJob
|
||||
queue_as :high
|
||||
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook)
|
||||
super(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,7 @@ class Account < ApplicationRecord
|
||||
has_many :categories, dependent: :destroy_async, class_name: '::Category'
|
||||
has_many :contacts, dependent: :destroy_async
|
||||
has_many :conversations, dependent: :destroy_async
|
||||
has_many :credit_transactions, dependent: :destroy_async
|
||||
has_many :csat_survey_responses, dependent: :destroy_async
|
||||
has_many :custom_attribute_definitions, dependent: :destroy_async
|
||||
has_many :custom_filters, dependent: :destroy_async
|
||||
|
||||
@@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 10
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :imap_password
|
||||
encrypts :smtp_password
|
||||
end
|
||||
|
||||
self.table_name = 'channel_email'
|
||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl,
|
||||
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
||||
|
||||
@@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord
|
||||
include Channelable
|
||||
include Reauthorizable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :page_access_token
|
||||
encrypts :user_access_token
|
||||
end
|
||||
|
||||
self.table_name = 'channel_facebook_pages'
|
||||
|
||||
validates :page_id, uniqueness: { scope: :account_id }
|
||||
|
||||
@@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord
|
||||
include Reauthorizable
|
||||
self.table_name = 'channel_instagram'
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :access_token if Chatwoot.encryption_configured?
|
||||
|
||||
AUTHORIZATION_ERROR_THRESHOLD = 1
|
||||
|
||||
validates :access_token, presence: true
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
class Channel::Line < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :line_channel_secret
|
||||
encrypts :line_channel_token
|
||||
end
|
||||
|
||||
self.table_name = 'channel_line'
|
||||
EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
class Channel::Telegram < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured?
|
||||
|
||||
self.table_name = 'channel_telegram'
|
||||
EDITABLE_ATTRS = [:bot_token].freeze
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
|
||||
self.table_name = 'channel_twilio_sms'
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :auth_token if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_sid, presence: true
|
||||
# The same parameter is used to store api_key_secret if api_key authentication is opted
|
||||
validates :auth_token, presence: true
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
class Channel::TwitterProfile < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
if Chatwoot.encryption_configured?
|
||||
encrypts :twitter_access_token
|
||||
encrypts :twitter_access_token_secret
|
||||
end
|
||||
|
||||
self.table_name = 'channel_twitter_profiles'
|
||||
|
||||
validates :profile_id, uniqueness: { scope: :account_id }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: credit_transactions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# amount :integer not null
|
||||
# credit_type :string not null
|
||||
# description :string
|
||||
# metadata :json
|
||||
# transaction_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_credit_transactions_on_account_id (account_id)
|
||||
# index_credit_transactions_on_account_id_and_created_at (account_id,created_at)
|
||||
#
|
||||
class CreditTransaction < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :amount, presence: true, numericality: { greater_than: 0 }
|
||||
validates :transaction_type, presence: true, inclusion: { in: %w[grant expire use topup] }
|
||||
validates :credit_type, presence: true, inclusion: { in: %w[monthly topup mixed] }
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
end
|
||||
@@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord
|
||||
before_validation :ensure_hook_type
|
||||
after_create :trigger_setup_if_crm
|
||||
|
||||
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
|
||||
encrypts :access_token, deterministic: true if Chatwoot.encryption_configured?
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :app_id, presence: true
|
||||
validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' }
|
||||
|
||||
@@ -30,4 +30,32 @@ class AccountPolicy < ApplicationPolicy
|
||||
def toggle_deletion?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def credits_balance?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_pricing_plans?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_topup_options?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_topup?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def v2_subscribe?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def cancel_subscription?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update_subscription?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,10 @@ if resource.custom_attributes.present?
|
||||
if resource.custom_attributes['marked_for_deletion_reason'].present?
|
||||
json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason']
|
||||
end
|
||||
# V2 Billing attributes
|
||||
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
|
||||
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
|
||||
json.stripe_cadence_id resource.custom_attributes['stripe_cadence_id'] if resource.custom_attributes['stripe_cadence_id'].present?
|
||||
end
|
||||
end
|
||||
json.domain @account.domain
|
||||
|
||||
@@ -75,7 +75,11 @@ module Chatwoot
|
||||
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
|
||||
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil)
|
||||
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil)
|
||||
# TODO: Remove once encryption is mandatory and legacy plaintext is migrated.
|
||||
config.active_record.encryption.support_unencrypted_data = true
|
||||
# Extend deterministic queries so they match both encrypted and plaintext rows
|
||||
config.active_record.encryption.extend_queries = true
|
||||
# Store a per-row key reference to support future key rotation
|
||||
config.active_record.encryption.store_key_references = true
|
||||
end
|
||||
end
|
||||
@@ -94,6 +98,8 @@ module Chatwoot
|
||||
end
|
||||
|
||||
def self.encryption_configured?
|
||||
# TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out),
|
||||
# remove this guard and assume encryption is always enabled.
|
||||
# Check if proper encryption keys are configured
|
||||
# MFA/2FA features should only be enabled when proper keys are set
|
||||
ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? &&
|
||||
|
||||
@@ -202,6 +202,8 @@ en:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
status:
|
||||
resolved: 'Conversation was marked resolved by %{user_name}'
|
||||
contact_resolved: 'Conversation was resolved by %{contact_name}'
|
||||
|
||||
@@ -431,6 +431,13 @@ Rails.application.routes.draw do
|
||||
post :subscription
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
# V2 Billing endpoints
|
||||
get :credits_balance
|
||||
get :v2_pricing_plans
|
||||
get :v2_topup_options
|
||||
post :v2_topup
|
||||
post :v2_subscribe
|
||||
post :cancel_subscription
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Stripe V2 Billing Scheduled Jobs
|
||||
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
|
||||
|
||||
v2_credit_sync:
|
||||
cron: "0 */6 * * *" # Every 6 hours
|
||||
class: "Enterprise::Billing::CreditSyncJob"
|
||||
queue: low
|
||||
description: "Sync V2 billing credits with Stripe"
|
||||
@@ -27,6 +27,7 @@
|
||||
- purgable
|
||||
- housekeeping
|
||||
- async_database_migration
|
||||
- bulk_reindex_low
|
||||
- active_storage_analysis
|
||||
- active_storage_purge
|
||||
- action_mailbox_incineration
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# Validate environment before starting
|
||||
if ENV['STRIPE_SECRET_KEY'].blank?
|
||||
puts '❌ ERROR: STRIPE_SECRET_KEY environment variable is not set'
|
||||
puts 'Please set it with: export STRIPE_SECRET_KEY=sk_test_...'
|
||||
exit 1
|
||||
end
|
||||
|
||||
account = Account.first
|
||||
if account.nil?
|
||||
puts '❌ ERROR: No accounts found in database'
|
||||
puts 'Please create an account first'
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "Using account: #{account.name} (ID: #{account.id})"
|
||||
puts "Stripe API: #{ENV.fetch('STRIPE_SECRET_KEY', nil)[0..10]}..."
|
||||
puts ''
|
||||
|
||||
configs = [
|
||||
{
|
||||
cpu_display_name: 'Credits',
|
||||
cpu_lookup_key: 'cpu_credits',
|
||||
meter_display_name: 'Cw meter',
|
||||
meter_event_name: 'chatwoot.usage',
|
||||
plan_display_name: 'Chatwoot Hacker',
|
||||
plan_lookup_key: 'chatwoot_hacker_v2',
|
||||
lookup_key: 'chatwoot_hacker_license_fee_v2',
|
||||
service_action_lookup_key: 'chatwoot_hacker_plan_v2_credits',
|
||||
metered_item_lookup_key: 'chatwoot_hacker_plan_v2_usage',
|
||||
licensed_item_display_name: 'Seat',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
license_fee_amount: '0',
|
||||
monthly_credit_amount: 0,
|
||||
rate_card_display_name: 'Rates',
|
||||
metered_item_display_name: 'Prompt',
|
||||
rate_value: 1,
|
||||
config_key: 'STRIPE_HACKER_PLAN_ID'
|
||||
},
|
||||
{
|
||||
cpu_display_name: 'Credits',
|
||||
cpu_lookup_key: 'cpu_credits',
|
||||
meter_display_name: 'Cw meter',
|
||||
meter_event_name: 'chatwoot.usage',
|
||||
plan_display_name: 'Chatwoot Startup',
|
||||
plan_lookup_key: 'chatwoot_startup_v2',
|
||||
lookup_key: 'chatwoot_startup_license_fee_v2',
|
||||
service_action_lookup_key: 'chatwoot_startup_plan_v2_credits',
|
||||
metered_item_lookup_key: 'chatwoot_startup_plan_v2_usage',
|
||||
licensed_item_display_name: 'Seat',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
license_fee_amount: '1900',
|
||||
monthly_credit_amount: 10_000,
|
||||
rate_card_display_name: 'Rates',
|
||||
metered_item_display_name: 'Prompt',
|
||||
rate_value: 1,
|
||||
config_key: 'STRIPE_STARTUP_PLAN_ID'
|
||||
},
|
||||
{
|
||||
cpu_display_name: 'Credits',
|
||||
cpu_lookup_key: 'cpu_credits',
|
||||
meter_display_name: 'Cw meter',
|
||||
meter_event_name: 'chatwoot.usage',
|
||||
plan_display_name: 'Chatwoot Business',
|
||||
plan_lookup_key: 'chatwoot_business_v2',
|
||||
lookup_key: 'chatwoot_business_plan_license_fee_v2',
|
||||
service_action_lookup_key: 'chatwoot_business_plan_v2_credits',
|
||||
metered_item_lookup_key: 'chatwoot_business_plan_v2_usage',
|
||||
licensed_item_display_name: 'Seat',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
license_fee_amount: '3900',
|
||||
monthly_credit_amount: 50_000,
|
||||
rate_card_display_name: 'Rates',
|
||||
metered_item_display_name: 'Prompt',
|
||||
rate_value: 1,
|
||||
config_key: 'STRIPE_BUSINESS_PLAN_ID'
|
||||
},
|
||||
{
|
||||
cpu_display_name: 'Credits',
|
||||
cpu_lookup_key: 'cpu_credits',
|
||||
meter_display_name: 'Cw meter',
|
||||
meter_event_name: 'chatwoot.usage',
|
||||
plan_display_name: 'Chatwoot Enterprise',
|
||||
plan_lookup_key: 'chatwoot_enterprise_plan_v2',
|
||||
lookup_key: 'chatwoot_enterprise_license_fee_v2',
|
||||
service_action_lookup_key: 'chatwoot_enterprise_plan_v2_credits',
|
||||
metered_item_lookup_key: 'chatwoot_enterprise_plan_v2_usage',
|
||||
licensed_item_display_name: 'Seat',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
license_fee_amount: '9900',
|
||||
monthly_credit_amount: 200_000,
|
||||
rate_card_display_name: 'Rates',
|
||||
metered_item_display_name: 'Prompt',
|
||||
rate_value: 1,
|
||||
config_key: 'STRIPE_ENTERPRISE_PLAN_ID'
|
||||
}
|
||||
]
|
||||
|
||||
service = Enterprise::Billing::V2::PricingPlanService.new(account: account)
|
||||
|
||||
puts 'Creating pricing plans...'
|
||||
puts ''
|
||||
|
||||
configs.each do |config|
|
||||
begin
|
||||
result = service.create_complete_pricing_plan(config)
|
||||
rescue StandardError => e
|
||||
result = { success: false, error: e.message }
|
||||
puts "Exception occurred: #{e.class}"
|
||||
puts "Message: #{e.message}"
|
||||
puts e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
if result[:success]
|
||||
# Save or update the pricing plan ID in InstallationConfig
|
||||
installation_config = InstallationConfig.find_or_initialize_by(name: config[:config_key])
|
||||
installation_config.value = result[:pricing_plan].id
|
||||
installation_config.save!
|
||||
|
||||
puts "✓ Created #{config[:plan_display_name]}"
|
||||
puts " Plan ID: #{result[:pricing_plan].id}"
|
||||
puts " CPU ID: #{result[:custom_pricing_unit].id}"
|
||||
puts " Meter ID: #{result[:meter].id}"
|
||||
else
|
||||
puts "✗ Failed to create #{config[:plan_display_name]}"
|
||||
puts " Error: #{result[:error]}" if result[:error]
|
||||
end
|
||||
puts ''
|
||||
end
|
||||
|
||||
puts 'Summary:'
|
||||
puts '--------'
|
||||
puts "CPU ID saved to: STRIPE_CUSTOM_PRICING_UNIT_ID = #{InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value}"
|
||||
puts "Meter ID saved to: STRIPE_METER_ID = #{InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value}"
|
||||
puts ''
|
||||
configs.each do |config|
|
||||
plan_id = InstallationConfig.find_by(name: config[:config_key])&.value
|
||||
puts "#{config[:config_key]} = #{plan_id}"
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class CreateCreditTransactions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :credit_transactions do |t|
|
||||
t.references :account, null: false
|
||||
t.string :transaction_type, null: false
|
||||
t.integer :amount, null: false
|
||||
t.string :credit_type, null: false
|
||||
t.string :description
|
||||
t.jsonb :metadata, default: {}
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :credit_transactions, [:account_id, :created_at]
|
||||
end
|
||||
end
|
||||
+14
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_07_111938) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -708,6 +708,19 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
|
||||
end
|
||||
|
||||
create_table "credit_transactions", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "transaction_type", null: false
|
||||
t.integer "amount", null: false
|
||||
t.string "credit_type", null: false
|
||||
t.string "description"
|
||||
t.json "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "created_at"], name: "index_credit_transactions_on_account_id_and_created_at"
|
||||
t.index ["account_id"], name: "index_credit_transactions_on_account_id"
|
||||
end
|
||||
|
||||
create_table "csat_survey_responses", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
|
||||
@@ -3,6 +3,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
before_action :fetch_account
|
||||
before_action :check_authorization
|
||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
||||
before_action :validate_topup_amount, only: [:v2_topup]
|
||||
|
||||
def subscription
|
||||
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
|
||||
@@ -55,6 +56,67 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
# V2 Billing Endpoints
|
||||
def credits_balance
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
balance = service.credit_balance
|
||||
|
||||
render json: {
|
||||
id: @account.id,
|
||||
monthly_credits: balance[:monthly],
|
||||
topup_credits: balance[:topup],
|
||||
total_credits: balance[:total],
|
||||
usage_this_month: balance[:usage_this_month],
|
||||
usage_total: balance[:usage_total]
|
||||
}
|
||||
end
|
||||
|
||||
def v2_pricing_plans
|
||||
plans = Enterprise::Billing::V2::PlanCatalog.plans
|
||||
render json: { pricing_plans: plans }
|
||||
end
|
||||
|
||||
def v2_topup_options
|
||||
options = Enterprise::Billing::V2::TopupCatalog.options
|
||||
render json: { topup_options: options }
|
||||
end
|
||||
|
||||
def v2_topup
|
||||
service = Enterprise::Billing::V2::TopupService.new(account: @account)
|
||||
result = service.create_topup(credits: params[:credits].to_i)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, message: result[:message] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def v2_subscribe
|
||||
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
|
||||
result = service.create_subscription_checkout(
|
||||
pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: subscription_quantity
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
|
||||
result = service.cancel_subscription
|
||||
|
||||
if result[:success]
|
||||
render json: result
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_cloud_env
|
||||
@@ -113,6 +175,18 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
render json: { redirect_url: redirect_url }
|
||||
end
|
||||
|
||||
def subscription_quantity
|
||||
quantity = params[:quantity].to_i
|
||||
quantity.positive? ? quantity : 1
|
||||
end
|
||||
|
||||
def validate_topup_amount
|
||||
amount = params[:credits].to_i
|
||||
return if amount.positive?
|
||||
|
||||
render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: current_user,
|
||||
|
||||
@@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
|
||||
# Attempt to verify the signature. If successful, we'll handle the event
|
||||
begin
|
||||
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
|
||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||
# Determine which webhook secret to use based on event type
|
||||
webhook_secret = determine_webhook_secret(payload)
|
||||
|
||||
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
|
||||
# Check if this is a V2 billing event
|
||||
if v2_billing_event?(event.type)
|
||||
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
|
||||
else
|
||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||
end
|
||||
# If we fail to verify the signature, then something was wrong with the request
|
||||
rescue JSON::ParserError, Stripe::SignatureVerificationError
|
||||
# Invalid payload
|
||||
@@ -18,4 +27,23 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
# We've successfully processed the event without blowing up
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_webhook_secret(payload)
|
||||
# Parse the payload to check event type without full verification
|
||||
parsed_payload = JSON.parse(payload)
|
||||
event_type = parsed_payload['type']
|
||||
|
||||
if v2_billing_event?(event_type)
|
||||
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
|
||||
else
|
||||
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
|
||||
end
|
||||
end
|
||||
|
||||
def v2_billing_event?(event_type)
|
||||
Rails.logger.debug { "V2 billing event: #{event_type}" }
|
||||
event_type.start_with?('v2.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -114,6 +114,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def handle_error(error)
|
||||
log_error(error)
|
||||
refund_credit
|
||||
process_action('handoff')
|
||||
true
|
||||
end
|
||||
@@ -125,4 +126,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def captain_v2_enabled?
|
||||
return account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
|
||||
def refund_credit
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(conversation: @conversation)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_captain_conversation',
|
||||
amount: -1, # Negative to refund
|
||||
metadata: {
|
||||
'assistant_id' => @assistant.id,
|
||||
'conversation_id' => @conversation.id,
|
||||
'inbox_id' => @inbox.id,
|
||||
'refund' => true
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,14 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
end
|
||||
|
||||
def generate_response(input)
|
||||
# Check and use credits before making API call
|
||||
credit_result = check_and_use_credits
|
||||
|
||||
unless credit_result[:success]
|
||||
error_message = credit_result[:message] || 'Insufficient credits'
|
||||
return { error: error_message, error_code: 402 }
|
||||
end
|
||||
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
response = request_chat_completion
|
||||
|
||||
@@ -113,4 +121,17 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
message_type: message_type
|
||||
)
|
||||
end
|
||||
|
||||
def check_and_use_credits
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(account: @account)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_copilot',
|
||||
amount: 1,
|
||||
metadata: {
|
||||
'assistant_id' => @assistant.id,
|
||||
'user_id' => @user&.id,
|
||||
'copilot_thread_id' => @copilot_thread&.id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
class Enterprise::Ai::CaptainCreditService
|
||||
attr_reader :account, :conversation
|
||||
|
||||
def initialize(account: nil, conversation: nil)
|
||||
@account = account || conversation&.account
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def check_and_use_credits(feature: 'ai_captain', amount: 1, metadata: {})
|
||||
# V1 accounts don't use credit system
|
||||
return { success: true } unless v2_enabled?
|
||||
|
||||
# V2 accounts use credit-based billing
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
service.use_credit(feature: feature, amount: amount, metadata: metadata)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startup plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
def update_plan_features(plan_name)
|
||||
if plan_name.blank? || plan_name == 'Hacker'
|
||||
disable_all_premium_features
|
||||
else
|
||||
enable_features_for_current_plan(plan_name)
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan or during plan changes)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan(plan_name)
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features(plan_name)
|
||||
end
|
||||
|
||||
def enable_plan_specific_features(plan_name)
|
||||
return if plan_name.blank?
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startup', 'Startups'
|
||||
# Startup plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startup features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage if account.respond_to?(:reset_response_usage)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,8 @@
|
||||
class Enterprise::Billing::HandleStripeEventService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startups plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
def perform(event:)
|
||||
@event = event
|
||||
|
||||
@@ -32,6 +11,10 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
process_subscription_updated
|
||||
when 'customer.subscription.deleted'
|
||||
process_subscription_deleted
|
||||
when 'billing.credit_grant.created'
|
||||
process_credit_grant_created
|
||||
when 'billing.credit_grant.updated'
|
||||
process_credit_grant_updated
|
||||
else
|
||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||
end
|
||||
@@ -46,7 +29,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
return if plan.blank? || account.blank?
|
||||
|
||||
update_account_attributes(subscription, plan)
|
||||
update_plan_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
update_plan_features(plan_name)
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
@@ -72,65 +56,22 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
|
||||
def update_plan_features
|
||||
if default_plan?
|
||||
disable_all_premium_features
|
||||
else
|
||||
enable_features_for_current_plan
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage
|
||||
end
|
||||
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startups'
|
||||
# Startups plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startups features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def subscription
|
||||
@subscription ||= @event.data.object
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
@account ||= begin
|
||||
customer_id = if @event.type.start_with?('billing.credit_grant')
|
||||
# Credit grant events have customer directly on the object
|
||||
@event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer']
|
||||
else
|
||||
# Subscription events have customer on subscription
|
||||
subscription.customer
|
||||
end
|
||||
|
||||
Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first
|
||||
end
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
@@ -144,12 +85,82 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
account.custom_attributes['plan_name'] == default_plan['name']
|
||||
end
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
def process_credit_grant_created
|
||||
grant_id = extract_credit_grant_id(@event.data.object)
|
||||
return if grant_id.blank?
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
# Retrieve the full credit grant object from Stripe API
|
||||
grant = retrieve_credit_grant(grant_id)
|
||||
return if grant.blank?
|
||||
|
||||
amount = extract_credit_amount(grant)
|
||||
return if amount.zero?
|
||||
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
|
||||
if grant.expires_at.present?
|
||||
service.sync_monthly_credits(amount)
|
||||
else
|
||||
service.add_topup_credits(amount)
|
||||
end
|
||||
end
|
||||
|
||||
def process_credit_grant_updated
|
||||
grant = @event.data.object
|
||||
# Check if grant has expired
|
||||
return unless grant.respond_to?(:expired_at) && grant.expired_at
|
||||
|
||||
# Grant has expired
|
||||
handle_credit_grant_expired
|
||||
|
||||
# Other updates (voided, amount changes, etc) - do nothing
|
||||
end
|
||||
|
||||
def handle_credit_grant_expired
|
||||
Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits
|
||||
end
|
||||
|
||||
def extract_credit_grant_id(grant_object)
|
||||
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
|
||||
end
|
||||
|
||||
def retrieve_credit_grant(grant_id)
|
||||
StripeV2Client.request(:get, "/v1/billing/credit_grants/#{grant_id}")
|
||||
end
|
||||
|
||||
def extract_credit_amount(grant)
|
||||
# First, try to get credits from metadata
|
||||
metadata = extract_attribute(grant, :metadata)
|
||||
if metadata
|
||||
credits = extract_attribute(metadata, :credits)
|
||||
return credits.to_i if credits.present? && credits.to_i.positive?
|
||||
end
|
||||
|
||||
# Fallback: extract from amount object
|
||||
amount_data = extract_attribute(grant, :amount)
|
||||
return 0 unless amount_data
|
||||
|
||||
amount_type = extract_attribute(amount_data, :type)
|
||||
|
||||
case amount_type
|
||||
when 'monetary'
|
||||
extract_amount_value(amount_data, :monetary)
|
||||
when 'custom_pricing_unit'
|
||||
extract_amount_value(amount_data, :custom_pricing_unit)
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def extract_attribute(object, attribute)
|
||||
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
|
||||
end
|
||||
|
||||
def extract_amount_value(amount_data, unit_type)
|
||||
unit = extract_attribute(amount_data, unit_type)
|
||||
return 0 unless unit
|
||||
|
||||
value = extract_attribute(unit, :value)
|
||||
value.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
class Enterprise::Billing::V2::BaseService
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(
|
||||
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil)
|
||||
)
|
||||
end
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
|
||||
def monthly_credits
|
||||
custom_attribute('monthly_credits').to_i
|
||||
end
|
||||
|
||||
def topup_credits
|
||||
custom_attribute('topup_credits').to_i
|
||||
end
|
||||
|
||||
def update_credits(monthly: nil, topup: nil)
|
||||
updates = {}
|
||||
updates['monthly_credits'] = monthly unless monthly.nil?
|
||||
updates['topup_credits'] = topup unless topup.nil?
|
||||
update_custom_attributes(updates)
|
||||
end
|
||||
|
||||
def update_custom_attributes(updates)
|
||||
return if updates.blank?
|
||||
|
||||
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
|
||||
updates.each do |key, value|
|
||||
current_attributes[key.to_s] = value
|
||||
end
|
||||
|
||||
account.update!(custom_attributes: current_attributes)
|
||||
end
|
||||
|
||||
def custom_attribute(key)
|
||||
account.custom_attributes&.[](key.to_s)
|
||||
end
|
||||
|
||||
def with_locked_account(&)
|
||||
account.with_lock(&)
|
||||
end
|
||||
|
||||
def log_credit_transaction(type:, amount:, credit_type:, description: nil, metadata: nil)
|
||||
account.credit_transactions.create!(
|
||||
transaction_type: type,
|
||||
amount: amount,
|
||||
credit_type: credit_type,
|
||||
description: description,
|
||||
metadata: (metadata || {}).stringify_keys
|
||||
)
|
||||
end
|
||||
|
||||
def with_stripe_error_handling
|
||||
yield
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
# Cancel subscription at period end using Stripe's V1 API
|
||||
# Subscription remains active until the end of the current billing period
|
||||
# Customer receives no refund/credit for remaining time
|
||||
#
|
||||
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
|
||||
#
|
||||
def cancel_subscription
|
||||
return { success: false, message: 'Not a V2 billing account' } unless v2_enabled?
|
||||
return { success: false, message: 'No active subscription' } unless active_subscription?
|
||||
|
||||
with_locked_account do
|
||||
subscription_data = cancel_at_period_end
|
||||
update_account_status(subscription_data)
|
||||
success_response = build_success_response(subscription_data)
|
||||
success_response
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: "Cancellation error: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cancel_at_period_end
|
||||
subscription_id = custom_attribute('stripe_subscription_id')
|
||||
raise StandardError, 'No subscription ID found' if subscription_id.blank?
|
||||
|
||||
# Build update parameters to cancel at period end
|
||||
params = {
|
||||
cancel_at_period_end: true
|
||||
}
|
||||
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
"/v1/subscriptions/#{subscription_id}",
|
||||
params,
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
def update_account_status(subscription_data)
|
||||
# Extract period end from subscription data
|
||||
current_period_end = extract_attribute(subscription_data, :current_period_end)
|
||||
period_end_time = current_period_end ? Time.zone.at(current_period_end).iso8601 : nil
|
||||
|
||||
# Mark subscription as cancelling (will be cancelled at period end)
|
||||
update_custom_attributes({
|
||||
'subscription_status' => 'cancel_at_period_end',
|
||||
'subscription_cancelled_at' => Time.current.iso8601,
|
||||
'subscription_period_end' => period_end_time
|
||||
})
|
||||
end
|
||||
|
||||
def build_success_response(subscription_data)
|
||||
current_period_end = extract_attribute(subscription_data, :current_period_end)
|
||||
period_end_time = current_period_end ? Time.zone.at(current_period_end) : nil
|
||||
|
||||
{
|
||||
success: true,
|
||||
cancel_at_period_end: true,
|
||||
period_end: period_end_time,
|
||||
message: 'Subscription will be cancelled at the end of the current billing period.'
|
||||
}
|
||||
end
|
||||
|
||||
def v2_enabled?
|
||||
custom_attribute('stripe_billing_version')&.to_i == 2
|
||||
end
|
||||
|
||||
def active_subscription?
|
||||
custom_attribute('subscription_status') == 'active'
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
|
||||
def extract_attribute(object, key)
|
||||
object.respond_to?(key) ? object.public_send(key) : object[key.to_s]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
# V2 Billing Checkout Service
|
||||
#
|
||||
# This service creates Checkout Sessions for V2 Billing subscriptions using
|
||||
# checkout_items with pricing_plan_subscription_item.
|
||||
#
|
||||
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
# Create a subscription checkout session
|
||||
#
|
||||
# Creates a Checkout Session with checkout_items containing the V2 Pricing Plan
|
||||
# and component configurations for the license fee.
|
||||
#
|
||||
# @param pricing_plan_id [String] V2 Pricing Plan ID
|
||||
# @param quantity [Integer] Number of licenses/seats
|
||||
# @return [Hash] { success:, session_id:, redirect_url: } or error
|
||||
#
|
||||
def create_subscription_checkout(pricing_plan_id:, quantity: 1)
|
||||
@pricing_plan_id = pricing_plan_id
|
||||
@quantity = quantity.to_i.positive? ? quantity.to_i : 1
|
||||
|
||||
base_url = ENV.fetch('FRONTEND_URL')
|
||||
@success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}"
|
||||
@cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing"
|
||||
|
||||
validate_params
|
||||
store_pending_subscription_quantity
|
||||
create_checkout_session
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe API error: #{e.message}", error: e }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_params
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank?
|
||||
end
|
||||
|
||||
def store_pending_subscription_quantity
|
||||
# Store quantity in custom_attributes for webhook to use
|
||||
# This is more reliable than extracting from subscription component_values
|
||||
update_custom_attributes({
|
||||
'pending_subscription_quantity' => @quantity,
|
||||
'pending_subscription_pricing_plan' => @pricing_plan_id
|
||||
})
|
||||
end
|
||||
|
||||
# Create Checkout Session with checkout_items
|
||||
#
|
||||
# Uses V2 Pricing Plans directly via checkout_items parameter.
|
||||
# This creates a subscription with V2 Billing features automatically.
|
||||
#
|
||||
def create_checkout_session
|
||||
customer_id = custom_attribute('stripe_customer_id')
|
||||
|
||||
session = StripeV2Client.request(
|
||||
:post,
|
||||
'/v1/checkout/sessions',
|
||||
checkout_session_params(customer_id),
|
||||
stripe_api_options
|
||||
)
|
||||
|
||||
build_success_response(session)
|
||||
end
|
||||
|
||||
def checkout_session_params(customer_id)
|
||||
{
|
||||
customer: customer_id,
|
||||
checkout_items: build_checkout_items,
|
||||
automatic_tax: {
|
||||
enabled: true
|
||||
},
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
shipping: 'auto'
|
||||
},
|
||||
success_url: @success_url,
|
||||
cancel_url: @cancel_url,
|
||||
metadata: session_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def build_checkout_items
|
||||
lookup_key = extract_license_lookup_key
|
||||
raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
|
||||
|
||||
[
|
||||
{
|
||||
type: 'pricing_plan_subscription_item',
|
||||
pricing_plan_subscription_item: {
|
||||
pricing_plan: @pricing_plan_id,
|
||||
component_configurations: {
|
||||
lookup_key => {
|
||||
type: 'license_fee_component',
|
||||
license_fee_component: {
|
||||
quantity: @quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def extract_license_lookup_key
|
||||
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
|
||||
end
|
||||
|
||||
def session_metadata
|
||||
{
|
||||
account_id: account.id,
|
||||
pricing_plan_id: @pricing_plan_id,
|
||||
quantity: @quantity,
|
||||
billing_version: 'v2'
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(session)
|
||||
session_id = session.respond_to?(:id) ? session.id : session['id']
|
||||
session_url = session.respond_to?(:url) ? session.url : session['url']
|
||||
|
||||
{
|
||||
success: true,
|
||||
session_id: session_id,
|
||||
redirect_url: session_url
|
||||
}
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{
|
||||
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil),
|
||||
stripe_version: '2025-08-27.preview;checkout_product_catalog_preview=v1'
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
def use_credit(feature: 'ai_captain', amount: 1, metadata: {})
|
||||
return { success: true, credits_used: 0, remaining: total_credits } if amount <= 0
|
||||
|
||||
with_locked_account do
|
||||
return { success: false, message: 'Insufficient credits' } unless total_credits >= amount
|
||||
|
||||
# Report usage to Stripe
|
||||
stripe_result = report_usage_to_stripe(amount, feature)
|
||||
return { success: false, message: stripe_result[:message] } unless stripe_result[:success]
|
||||
|
||||
# Deduct credits locally (monthly first, then topup)
|
||||
deduct_credits(amount)
|
||||
|
||||
# Log transaction
|
||||
log_credit_transaction(
|
||||
type: 'use',
|
||||
amount: amount,
|
||||
credit_type: determine_credit_type(amount),
|
||||
description: "Used for #{feature}",
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
{ success: true, credits_used: amount, remaining: total_credits }
|
||||
end
|
||||
end
|
||||
|
||||
def sync_monthly_credits(amount, metadata: {})
|
||||
with_locked_account do
|
||||
update_credits(monthly: amount)
|
||||
if amount.positive?
|
||||
log_credit_transaction(
|
||||
type: 'grant',
|
||||
amount: amount,
|
||||
credit_type: 'monthly',
|
||||
description: 'Monthly credits from Stripe',
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def add_topup_credits(amount, metadata: {})
|
||||
with_locked_account do
|
||||
update_credits(topup: topup_credits + amount)
|
||||
log_credit_transaction(
|
||||
type: 'topup',
|
||||
amount: amount,
|
||||
credit_type: 'topup',
|
||||
description: 'Topup credits added',
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def expire_monthly_credits
|
||||
with_locked_account do
|
||||
expired = monthly_credits
|
||||
update_credits(monthly: 0) if expired.positive?
|
||||
expired
|
||||
end
|
||||
end
|
||||
|
||||
def credit_balance
|
||||
usage_stats = calculate_usage_stats
|
||||
|
||||
{
|
||||
monthly: monthly_credits,
|
||||
topup: topup_credits,
|
||||
total: total_credits,
|
||||
usage_this_month: usage_stats[:this_month],
|
||||
usage_total: usage_stats[:total]
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_usage_stats
|
||||
month_start = Time.current.beginning_of_month
|
||||
|
||||
this_month_usage = account.credit_transactions
|
||||
.where(transaction_type: 'use', created_at: month_start..Time.current)
|
||||
.sum(:amount)
|
||||
.abs
|
||||
|
||||
total_usage = account.credit_transactions
|
||||
.where(transaction_type: 'use')
|
||||
.sum(:amount)
|
||||
.abs
|
||||
|
||||
{
|
||||
this_month: this_month_usage,
|
||||
total: total_usage
|
||||
}
|
||||
end
|
||||
|
||||
def total_credits
|
||||
monthly_credits + topup_credits
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report_usage_to_stripe(amount, feature)
|
||||
Enterprise::Billing::V2::UsageReporterService.new(account: account).report(amount, feature)
|
||||
end
|
||||
|
||||
def deduct_credits(amount)
|
||||
if monthly_credits >= amount
|
||||
update_credits(monthly: monthly_credits - amount)
|
||||
else
|
||||
remaining = amount - monthly_credits
|
||||
update_credits(monthly: 0, topup: topup_credits - remaining)
|
||||
end
|
||||
end
|
||||
|
||||
def determine_credit_type(amount)
|
||||
if monthly_credits >= amount
|
||||
'monthly'
|
||||
elsif monthly_credits.positive?
|
||||
'mixed'
|
||||
else
|
||||
'topup'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
module Enterprise::Billing::V2::PlanCatalog
|
||||
DEFAULT_CURRENCY = 'usd'.freeze
|
||||
CREDIT_UNIT = 'Credits'.freeze
|
||||
|
||||
PLAN_DEFINITIONS = [
|
||||
{
|
||||
key: :free,
|
||||
display_name: 'Chatwoot Hacker',
|
||||
base_fee: 0.0,
|
||||
monthly_credits: 0,
|
||||
config_key: 'STRIPE_HACKER_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :startup,
|
||||
display_name: 'Chatwoot Startup',
|
||||
base_fee: 19.0,
|
||||
monthly_credits: 10_000,
|
||||
config_key: 'STRIPE_STARTUP_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :business,
|
||||
display_name: 'Chatwoot Business',
|
||||
base_fee: 39.0,
|
||||
monthly_credits: 50_000,
|
||||
config_key: 'STRIPE_BUSINESS_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :enterprise,
|
||||
display_name: 'Chatwoot Enterprise',
|
||||
base_fee: 99.0,
|
||||
monthly_credits: 200_000,
|
||||
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2'
|
||||
}
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def plans
|
||||
PLAN_DEFINITIONS.map do |definition|
|
||||
plan_id = plan_id_for(definition)
|
||||
build_plan(definition, plan_id)
|
||||
end
|
||||
end
|
||||
|
||||
def definition_for(plan_id)
|
||||
PLAN_DEFINITIONS.each do |definition|
|
||||
return definition if plan_id_for(definition) == plan_id
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def monthly_credits_for(plan_id)
|
||||
definition = definition_for(plan_id)
|
||||
definition ? definition[:monthly_credits] : nil
|
||||
end
|
||||
|
||||
def plan_id_for(definition)
|
||||
InstallationConfig.find_by(name: definition[:config_key])&.value
|
||||
end
|
||||
|
||||
def lookup_key_for_plan(plan_id)
|
||||
# Returns the licensed_item_lookup_key for checkout sessions
|
||||
definition = definition_for(plan_id)
|
||||
definition&.dig(:licensed_item_lookup_key)
|
||||
end
|
||||
|
||||
def build_plan(definition, plan_id)
|
||||
{
|
||||
id: plan_id,
|
||||
display_name: definition[:display_name],
|
||||
currency: DEFAULT_CURRENCY,
|
||||
tax_behavior: 'exclusive',
|
||||
components: build_components(definition)
|
||||
}
|
||||
end
|
||||
|
||||
def build_components(definition)
|
||||
components = [service_action_component(definition), rate_card_component(definition)]
|
||||
components << license_fee_component(definition) if definition[:base_fee]&.positive?
|
||||
components
|
||||
end
|
||||
|
||||
def service_action_component(definition)
|
||||
{
|
||||
type: 'service_action',
|
||||
name: 'Monthly Credits',
|
||||
credit_amount: definition[:monthly_credits],
|
||||
credit_unit: CREDIT_UNIT
|
||||
}
|
||||
end
|
||||
|
||||
def rate_card_component(definition)
|
||||
{
|
||||
type: 'rate_card',
|
||||
name: 'Overage Rate',
|
||||
overage_rate: definition[:overage_rate],
|
||||
rate_unit: CREDIT_UNIT,
|
||||
meter_id: nil
|
||||
}
|
||||
end
|
||||
|
||||
def license_fee_component(definition)
|
||||
{
|
||||
type: 'license_fee',
|
||||
name: 'Base Fee',
|
||||
unit_amount: definition[:base_fee].round(2)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,179 @@
|
||||
class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService
|
||||
def add_license_fee_component(plan, config)
|
||||
licensed_item = create_licensed_item(
|
||||
display_name: config[:licensed_item_display_name],
|
||||
lookup_key: config[:lookup_key],
|
||||
unit_label: config[:licensed_item_unit_label]
|
||||
)
|
||||
|
||||
license_fee = create_license_fee(
|
||||
display_name: config[:license_fee_display_name],
|
||||
unit_amount: config[:license_fee_amount],
|
||||
licensed_item_id: licensed_item.id,
|
||||
lookup_key: config[:lookup_key]
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'license_fee',
|
||||
data: { id: license_fee.id, version: license_fee.latest_version },
|
||||
lookup_key: config[:lookup_key]
|
||||
)
|
||||
end
|
||||
|
||||
def add_service_action_component(plan, config, cpu)
|
||||
action = create_service_action(
|
||||
lookup_key: config[:service_action_lookup_key],
|
||||
credit_amount: config[:monthly_credit_amount],
|
||||
cpu_id: cpu.id
|
||||
)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'service_action',
|
||||
data: { id: action.id },
|
||||
lookup_key: config[:service_action_lookup_key]
|
||||
)
|
||||
|
||||
action
|
||||
end
|
||||
|
||||
def add_rate_card_component(plan, config, meter, cpu)
|
||||
card = create_rate_card(display_name: config[:rate_card_display_name])
|
||||
|
||||
item = create_metered_item(
|
||||
display_name: config[:metered_item_display_name],
|
||||
lookup_key: config[:metered_item_lookup_key],
|
||||
meter_id: meter.id
|
||||
)
|
||||
|
||||
add_rate(card_id: card.id, item_id: item.id, cpu_id: cpu.id, value: config[:rate_value] || 1)
|
||||
|
||||
add_component(
|
||||
plan_id: plan.id,
|
||||
type: 'rate_card',
|
||||
data: { id: card.id, version: card.latest_version },
|
||||
lookup_key: config[:metered_item_lookup_key]
|
||||
)
|
||||
|
||||
card
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_licensed_item(display_name:, lookup_key:, unit_label:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/licensed_items',
|
||||
{ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/license_fees',
|
||||
{
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive',
|
||||
unit_amount: unit_amount.to_s,
|
||||
licensed_item: licensed_item_id,
|
||||
lookup_key: lookup_key
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_service_action(lookup_key:, credit_amount:, cpu_id:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/service_actions',
|
||||
service_action_params(lookup_key, credit_amount, cpu_id),
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
def service_action_params(lookup_key, credit_amount, cpu_id)
|
||||
{
|
||||
lookup_key: lookup_key,
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
type: 'credit_grant',
|
||||
credit_grant: credit_grant_config(credit_amount, cpu_id)
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grant_config(credit_amount, cpu_id)
|
||||
{
|
||||
name: 'Monthly Credits',
|
||||
amount: {
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: { id: cpu_id, value: credit_amount.to_s }
|
||||
},
|
||||
expiry_config: { type: 'end_of_service_period' },
|
||||
applicability_config: { scope: { price_type: 'metered' } }
|
||||
}
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
|
||||
def create_rate_card(display_name:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/rate_cards',
|
||||
{
|
||||
display_name: display_name,
|
||||
currency: 'usd',
|
||||
service_interval: 'month',
|
||||
service_interval_count: 1,
|
||||
tax_behavior: 'exclusive'
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_metered_item(display_name:, lookup_key:, meter_id:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/metered_items',
|
||||
{ display_name: display_name, lookup_key: lookup_key, meter: meter_id },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def add_rate(card_id:, item_id:, cpu_id:, value:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
"/v2/billing/rate_cards/#{card_id}/rates",
|
||||
{
|
||||
metered_item: item_id,
|
||||
custom_pricing_unit_amount: { id: cpu_id, value: value.to_s }
|
||||
},
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def add_component(plan_id:, type:, data:, lookup_key:)
|
||||
params = case type
|
||||
when 'license_fee'
|
||||
{ type: 'license_fee', license_fee: data, lookup_key: lookup_key }
|
||||
when 'service_action'
|
||||
{ type: 'service_action', service_action: data, lookup_key: lookup_key }
|
||||
when 'rate_card'
|
||||
{ type: 'rate_card', rate_card: data, lookup_key: lookup_key }
|
||||
end
|
||||
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
"/v2/billing/pricing_plans/#{plan_id}/components",
|
||||
params,
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService
|
||||
def create_custom_pricing_unit(display_name:, lookup_key:)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/custom_pricing_units',
|
||||
{ display_name: display_name, lookup_key: lookup_key },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_meter(display_name:, event_name:)
|
||||
params = {
|
||||
'display_name' => display_name,
|
||||
'event_name' => event_name,
|
||||
'default_aggregation[formula]' => 'sum',
|
||||
'customer_mapping[type]' => 'by_id',
|
||||
'customer_mapping[event_payload_key]' => 'stripe_customer_id',
|
||||
'value_settings[event_payload_key]' => 'value'
|
||||
}
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v1/billing/meters',
|
||||
params,
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive')
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
'/v2/billing/pricing_plans',
|
||||
{ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def create_complete_pricing_plan(config)
|
||||
# Use shared CPU and meter if available, otherwise create new ones
|
||||
cpu = get_or_create_cpu(config)
|
||||
meter = get_or_create_meter(config)
|
||||
|
||||
plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key])
|
||||
|
||||
builder = component_builder
|
||||
builder.add_license_fee_component(plan, config)
|
||||
service_action = builder.add_service_action_component(plan, config, cpu)
|
||||
rate_card = builder.add_rate_card_component(plan, config, meter, cpu)
|
||||
|
||||
# Make the latest version live
|
||||
make_plan_version_live(plan.id)
|
||||
|
||||
build_plan_result(plan, cpu, meter, rate_card, service_action)
|
||||
end
|
||||
|
||||
def get_or_create_cpu(config)
|
||||
shared_cpu_id = InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value
|
||||
|
||||
if shared_cpu_id
|
||||
# Return existing CPU as OpenStruct to match create response
|
||||
OpenStruct.new(id: shared_cpu_id)
|
||||
else
|
||||
# Create new CPU if not using shared
|
||||
cpu = create_custom_pricing_unit(
|
||||
display_name: config[:cpu_display_name],
|
||||
lookup_key: config[:cpu_lookup_key]
|
||||
)
|
||||
|
||||
installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')
|
||||
installation_config.value = cpu.id
|
||||
installation_config.save!
|
||||
|
||||
cpu
|
||||
end
|
||||
end
|
||||
|
||||
def get_or_create_meter(config)
|
||||
# Check for shared meter first
|
||||
shared_meter_id = InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value ||
|
||||
ENV.fetch('STRIPE_METER_ID', nil)
|
||||
|
||||
if shared_meter_id
|
||||
# Return existing meter as OpenStruct to match create response
|
||||
OpenStruct.new(id: shared_meter_id)
|
||||
else
|
||||
# Create new meter if not using shared
|
||||
meter = create_meter(
|
||||
display_name: config[:meter_display_name],
|
||||
event_name: config[:meter_event_name]
|
||||
)
|
||||
|
||||
installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_METER_ID')
|
||||
installation_config.value = meter.id
|
||||
installation_config.save!
|
||||
|
||||
meter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def component_builder
|
||||
@component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account)
|
||||
end
|
||||
|
||||
def make_plan_version_live(plan_id)
|
||||
StripeV2Client.request(
|
||||
:post,
|
||||
"/v2/billing/pricing_plans/#{plan_id}",
|
||||
{ live_version: 'latest' },
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
|
||||
def build_plan_result(plan, cpu, meter, rate_card, service_action)
|
||||
{
|
||||
success: true,
|
||||
pricing_plan: plan,
|
||||
custom_pricing_unit: cpu,
|
||||
meter: meter,
|
||||
rate_card: rate_card,
|
||||
service_action: service_action
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
def provision(subscription_id:)
|
||||
# Retrieve pricing plan subscription details from Stripe V2 API
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
# Extract details from the subscription
|
||||
pricing_plan_id = extract_pricing_plan_id(subscription)
|
||||
quantity = extract_subscription_quantity(subscription)
|
||||
# Update account with subscription details
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity)
|
||||
|
||||
# Provision the subscription: sync credits and enable features
|
||||
provision_subscription(pricing_plan_id) if pricing_plan_id.present?
|
||||
|
||||
build_success_response(subscription_id, pricing_plan_id, quantity)
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
rescue StandardError => e
|
||||
{ success: false, message: "Provisioning error: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_success_response(subscription_id, pricing_plan_id, quantity)
|
||||
{
|
||||
success: true,
|
||||
subscription_id: subscription_id,
|
||||
pricing_plan_id: pricing_plan_id,
|
||||
quantity: quantity,
|
||||
message: 'Subscription provisioned successfully'
|
||||
}
|
||||
end
|
||||
|
||||
def retrieve_pricing_plan_subscription(subscription_id)
|
||||
StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
def extract_pricing_plan_id(subscription)
|
||||
# Extract pricing_plan from the subscription object
|
||||
pricing_plan = subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan']
|
||||
pricing_plan.is_a?(String) ? pricing_plan : pricing_plan&.[]('id') || pricing_plan&.dig(:id)
|
||||
end
|
||||
|
||||
def extract_subscription_quantity(_subscription)
|
||||
# Get quantity from account custom_attributes (set during checkout)
|
||||
pending_quantity = account.custom_attributes['pending_subscription_quantity']
|
||||
if pending_quantity.present? && pending_quantity.to_i.positive?
|
||||
Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}"
|
||||
return pending_quantity.to_i
|
||||
end
|
||||
|
||||
Rails.logger.warn '[V2 Billing] No pending_subscription_quantity found in custom_attributes, defaulting to quantity=1'
|
||||
1
|
||||
end
|
||||
|
||||
def update_subscription_details(subscription_id, pricing_plan_id, quantity)
|
||||
Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \
|
||||
"pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}"
|
||||
|
||||
attributes = {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_subscription_id' => subscription_id,
|
||||
'subscribed_quantity' => quantity,
|
||||
'subscription_status' => 'active',
|
||||
'pending_subscription_quantity' => nil,
|
||||
'pending_subscription_pricing_plan' => nil
|
||||
}
|
||||
attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present?
|
||||
|
||||
# Add plan name from catalog
|
||||
if pricing_plan_id.present?
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
|
||||
end
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
def provision_subscription(pricing_plan_id)
|
||||
# Sync monthly credits based on plan
|
||||
sync_plan_credits(pricing_plan_id)
|
||||
|
||||
# Extract plan name and enable features using PlanFeatureManager
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
if plan_definition
|
||||
plan_name = extract_plan_name(plan_definition)
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
|
||||
# Reset captain usage after provisioning
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def sync_plan_credits(pricing_plan_id)
|
||||
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
|
||||
return unless plan_credits
|
||||
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_credits(plan_credits.to_i)
|
||||
end
|
||||
|
||||
def extract_plan_name(plan_definition)
|
||||
# Extract plan name like "Startup", "Business", or "Enterprise" from display_name
|
||||
# e.g., "Chatwoot Startup" -> "Startup"
|
||||
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
module Enterprise::Billing::V2::TopupCatalog
|
||||
DEFAULT_TOPUPS = [
|
||||
{ credits: 100, amount: 5.0 },
|
||||
{ credits: 400, amount: 10.0 },
|
||||
{ credits: 1000, amount: 20.0 },
|
||||
{ credits: 5000, amount: 50.0 }
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def options
|
||||
custom_options = InstallationConfig.find_by(name: 'STRIPE_TOPUP_OPTIONS')&.value
|
||||
parsed = parse_options(custom_options)
|
||||
(parsed.presence || DEFAULT_TOPUPS).sort_by { |opt| opt[:credits] }.map do |option|
|
||||
{
|
||||
credits: option[:credits],
|
||||
amount: option[:amount],
|
||||
currency: option[:currency] || 'usd'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_options(raw)
|
||||
return [] if raw.blank?
|
||||
|
||||
JSON.parse(raw, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
[]
|
||||
end
|
||||
|
||||
def find_option(credits)
|
||||
options.find { |option| option[:credits].to_i == credits.to_i }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseService
|
||||
def create_topup(credits:)
|
||||
validation_result = validate_topup_request(credits)
|
||||
return validation_result unless validation_result[:valid]
|
||||
|
||||
topup_definition = validation_result[:topup_definition]
|
||||
amount_cents = (topup_definition[:amount] * 100).to_i
|
||||
currency = topup_definition[:currency] || 'usd'
|
||||
|
||||
with_locked_account do
|
||||
process_topup_transaction(credits, amount_cents, currency, topup_definition[:amount])
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: "Stripe error: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_topup_request(credits)
|
||||
return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive?
|
||||
|
||||
topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits)
|
||||
return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition
|
||||
return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank?
|
||||
|
||||
{ valid: true, topup_definition: topup_definition }
|
||||
end
|
||||
|
||||
def process_topup_transaction(credits, amount_cents, currency, amount)
|
||||
invoice = create_topup_invoice(currency)
|
||||
return { success: false, message: 'Failed to create invoice' } unless invoice
|
||||
|
||||
invoice_item = create_topup_invoice_item(invoice.id, amount_cents, currency, credits)
|
||||
return { success: false, message: 'Failed to create invoice item' } unless invoice_item
|
||||
|
||||
finalized_invoice = finalize_topup_invoice(invoice.id)
|
||||
return { success: false, message: 'Failed to finalize invoice' } unless finalized_invoice
|
||||
|
||||
paid_invoice = pay_invoice(invoice.id)
|
||||
return { success: false, message: 'Failed to pay invoice' } unless paid_invoice
|
||||
|
||||
credit_grant = create_stripe_credit_grant(amount_cents, currency, credits)
|
||||
return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant
|
||||
|
||||
# Credits will be added by webhook when Stripe sends billing.credit_grant.created event
|
||||
build_success_response(credits, amount, currency, invoice.id, credit_grant['id'])
|
||||
end
|
||||
|
||||
def build_success_response(credits, amount, currency, invoice_id, credit_grant_id)
|
||||
{
|
||||
success: true,
|
||||
message: 'Top-up purchased successfully',
|
||||
credits: credits,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
invoice_id: invoice_id,
|
||||
credit_grant_id: credit_grant_id
|
||||
}
|
||||
end
|
||||
|
||||
# Create Invoice following Stripe UBB Integration Guide
|
||||
def create_topup_invoice(currency)
|
||||
Stripe::Invoice.create(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
currency: currency,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: false, # We'll finalize it manually
|
||||
metadata: {
|
||||
account_id: account.id.to_s,
|
||||
topup: 'true'
|
||||
}
|
||||
},
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
# Create Invoice Item with topup amount
|
||||
def create_topup_invoice_item(invoice_id, amount_cents, currency, credits)
|
||||
Stripe::InvoiceItem.create(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
amount: amount_cents,
|
||||
currency: currency,
|
||||
invoice: invoice_id,
|
||||
description: "Credit Topup: #{credits} credits",
|
||||
metadata: {
|
||||
account_id: account.id.to_s,
|
||||
credits: credits.to_s,
|
||||
topup: 'true'
|
||||
}
|
||||
},
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
# Finalize Invoice for payment
|
||||
def finalize_topup_invoice(invoice_id)
|
||||
Stripe::Invoice.finalize_invoice(
|
||||
invoice_id,
|
||||
{ auto_advance: false }, # We'll pay it explicitly
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
# Pay the invoice explicitly
|
||||
def pay_invoice(invoice_id)
|
||||
Stripe::Invoice.pay(
|
||||
invoice_id,
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
# Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit)
|
||||
# Following Stripe UBB Integration Guide section 8
|
||||
def create_stripe_credit_grant(amount_cents, currency, credits)
|
||||
Stripe::Billing::CreditGrant.create(
|
||||
credit_grant_params(amount_cents, currency, credits),
|
||||
stripe_api_options
|
||||
)
|
||||
end
|
||||
|
||||
def credit_grant_params(amount_cents, currency, credits)
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
name: "Topup: #{credits} credits",
|
||||
amount: credit_grant_amount(amount_cents, currency),
|
||||
applicability_config: credit_grant_applicability,
|
||||
category: 'paid',
|
||||
metadata: credit_grant_metadata(credits)
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grant_amount(amount_cents, currency)
|
||||
{
|
||||
type: 'monetary',
|
||||
monetary: {
|
||||
currency: currency,
|
||||
value: amount_cents
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grant_applicability
|
||||
# Apply credit grant to all metered usage for this customer
|
||||
# This ensures topup credits offset meter-based billing
|
||||
{
|
||||
scope: {
|
||||
price_type: 'metered'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def credit_grant_metadata(credits)
|
||||
{
|
||||
account_id: account.id.to_s,
|
||||
source: 'topup',
|
||||
credits: credits.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
custom_attribute('stripe_customer_id')
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
|
||||
def report(credits_used, _feature = nil)
|
||||
return { success: false, message: 'V2 billing not enabled' } unless v2_enabled?
|
||||
return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration?
|
||||
|
||||
event = Stripe::Billing::MeterEvent.create(
|
||||
meter_event_params(credits_used),
|
||||
stripe_api_options
|
||||
)
|
||||
|
||||
{ success: true, event_id: event.identifier }
|
||||
rescue Stripe::StripeError => e
|
||||
{ success: false, message: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_configuration?
|
||||
custom_attribute('stripe_customer_id').present? && meter_event_name.present?
|
||||
end
|
||||
|
||||
def meter_event_params(credits_used)
|
||||
{
|
||||
event_name: meter_event_name,
|
||||
payload: {
|
||||
value: credits_used.to_s,
|
||||
stripe_customer_id: custom_attribute('stripe_customer_id')
|
||||
},
|
||||
identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}"
|
||||
}
|
||||
end
|
||||
|
||||
def meter_event_name
|
||||
# Use shared meter event name from ENV/InstallationConfig (preferred)
|
||||
# Falls back to account-specific event name for backward compatibility
|
||||
shared_event_name = InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value ||
|
||||
ENV.fetch('STRIPE_METER_EVENT_NAME', nil)
|
||||
|
||||
shared_event_name || custom_attribute('stripe_meter_event_name')
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Enterprise::Billing::V2::WebhookHandlerService
|
||||
def perform(event:)
|
||||
@event = event
|
||||
|
||||
return { success: false, message: 'Account not found' } if account.blank?
|
||||
|
||||
case @event.type
|
||||
when 'v2.billing.pricing_plan_subscription.servicing_activated'
|
||||
Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}"
|
||||
handle_subscription_servicing_activated(@event.related_object.id)
|
||||
else
|
||||
{ success: true }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error processing V2 webhook: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@account ||= begin
|
||||
related_object = @event.related_object
|
||||
subscription_id = related_object.id
|
||||
|
||||
customer_id = fetch_customer_id_from_subscription(subscription_id)
|
||||
found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present?
|
||||
|
||||
Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank?
|
||||
|
||||
found_account
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_customer_id_from_subscription(subscription_id)
|
||||
subscription = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
return nil unless subscription&.billing_cadence
|
||||
|
||||
cadence = StripeV2Client.request(
|
||||
:get,
|
||||
"/v2/billing/cadences/#{subscription.billing_cadence}",
|
||||
{},
|
||||
stripe_api_options
|
||||
)
|
||||
cadence.payer&.customer
|
||||
end
|
||||
|
||||
def handle_subscription_servicing_activated(subscription_id)
|
||||
Enterprise::Billing::V2::SubscriptionProvisioningService
|
||||
.new(account: account)
|
||||
.provision(subscription_id: subscription_id)
|
||||
end
|
||||
|
||||
def stripe_api_options
|
||||
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,10 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
transcriptions = transcribe_audio
|
||||
Rails.logger.info "Audio transcription successful: #{transcriptions}"
|
||||
{ success: true, transcriptions: transcriptions }
|
||||
rescue StandardError => e
|
||||
# Refund credit if transcription failed
|
||||
refund_credit
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
@@ -26,6 +30,20 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def refund_credit
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(account: account)
|
||||
credit_service.check_and_use_credits(
|
||||
feature: 'ai_audio_transcription',
|
||||
amount: -1, # Negative to refund
|
||||
metadata: {
|
||||
'message_id' => message.id,
|
||||
'attachment_id' => attachment.id,
|
||||
'conversation_id' => message.conversation_id,
|
||||
'refund' => true
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
temp_dir = Rails.root.join('tmp/uploads')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
@@ -3,6 +3,30 @@ module Enterprise::Integrations::OpenaiProcessorService
|
||||
make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[label_suggestion].freeze
|
||||
|
||||
def perform
|
||||
# Check and use credits if account is on V2 billing
|
||||
if v2_enabled?
|
||||
credit_service = Enterprise::Ai::CaptainCreditService.new(
|
||||
account: hook.account,
|
||||
conversation: conversation
|
||||
)
|
||||
|
||||
result = credit_service.check_and_use_credits(
|
||||
feature: "ai_#{event_name}",
|
||||
amount: 1,
|
||||
metadata: {
|
||||
'event_name' => event_name,
|
||||
'conversation_id' => conversation&.id
|
||||
}
|
||||
)
|
||||
|
||||
return { error: 'Insufficient credits', error_code: 402 } unless result[:success]
|
||||
end
|
||||
|
||||
# Call the parent perform method
|
||||
super
|
||||
end
|
||||
|
||||
def label_suggestion_message
|
||||
payload = label_suggestion_body
|
||||
return nil if payload.blank?
|
||||
@@ -79,4 +103,8 @@ module Enterprise::Integrations::OpenaiProcessorService
|
||||
def label_suggestions_enabled?
|
||||
hook.settings['label_suggestion'].present?
|
||||
end
|
||||
|
||||
def v2_enabled?
|
||||
ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
module StripeV2Client
|
||||
class << self
|
||||
def request(method, path, params = {}, options = {})
|
||||
api_key = options[:api_key] || ENV.fetch('STRIPE_SECRET_KEY', nil)
|
||||
stripe_version = options[:stripe_version] || '2025-08-27.preview'
|
||||
|
||||
uri = URI("https://api.stripe.com#{path}")
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
request = build_request(method, uri, params, path)
|
||||
request['Authorization'] = "Bearer #{api_key}"
|
||||
request['Stripe-Version'] = stripe_version
|
||||
|
||||
response = http.request(request)
|
||||
parse_response(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_request(method, uri, params, path)
|
||||
case method
|
||||
when :get
|
||||
Net::HTTP::Get.new(uri)
|
||||
when :post
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
if v2_endpoint?(path)
|
||||
# V2 endpoints use JSON
|
||||
req.body = params.to_json unless params.empty?
|
||||
req['Content-Type'] = 'application/json'
|
||||
else
|
||||
# V1 endpoints (including checkout sessions) use form data
|
||||
# even with preview API versions
|
||||
req.body = encode_nested_params(params) unless params.empty?
|
||||
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
end
|
||||
req
|
||||
when :delete
|
||||
Net::HTTP::Delete.new(uri)
|
||||
end
|
||||
end
|
||||
|
||||
def v2_endpoint?(path)
|
||||
path.start_with?('/v2/')
|
||||
end
|
||||
|
||||
# Encode nested parameters for form submission
|
||||
# Stripe expects nested params like: checkout_items[0][type]=value
|
||||
def encode_nested_params(params, prefix = nil)
|
||||
pairs = []
|
||||
params.each do |key, value|
|
||||
full_key = prefix ? "#{prefix}[#{key}]" : key.to_s
|
||||
pairs.concat(encode_param_value(full_key, value))
|
||||
end
|
||||
pairs.join('&')
|
||||
end
|
||||
|
||||
def encode_param_value(key, value)
|
||||
case value
|
||||
when Hash
|
||||
[encode_nested_params(value, key)]
|
||||
when Array
|
||||
value.each_with_index.map { |item, index| encode_nested_params(item, "#{key}[#{index}]") }
|
||||
when true, false
|
||||
["#{CGI.escape(key)}=#{value}"]
|
||||
when nil
|
||||
[]
|
||||
else
|
||||
["#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"]
|
||||
end
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
# Check for Stripe error responses
|
||||
if body.is_a?(Hash) && body['error']
|
||||
error = body['error']
|
||||
raise Stripe::StripeError, "#{error['code']}: #{error['message']}"
|
||||
end
|
||||
|
||||
# Convert to OpenStruct for dot notation access (mimicking Stripe SDK objects)
|
||||
case body
|
||||
when Hash
|
||||
recursive_to_struct(body)
|
||||
when Array
|
||||
body.map { |item| recursive_to_struct(item) }
|
||||
else
|
||||
body
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
response.body
|
||||
end
|
||||
|
||||
def recursive_to_struct(hash)
|
||||
return hash unless hash.is_a?(Hash)
|
||||
|
||||
OpenStruct.new(hash.transform_values do |value|
|
||||
case value
|
||||
when Hash
|
||||
recursive_to_struct(value)
|
||||
when Array
|
||||
value.map { |item| item.is_a?(Hash) ? recursive_to_struct(item) : item }
|
||||
else
|
||||
value
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,8 +16,11 @@ class Seeders::Reports::ConversationCreator
|
||||
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def create_conversation(created_at:)
|
||||
conversation = nil
|
||||
should_resolve = false
|
||||
resolution_time = nil
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
travel_to(created_at) do
|
||||
@@ -26,14 +29,35 @@ class Seeders::Reports::ConversationCreator
|
||||
|
||||
add_labels_to_conversation(conversation)
|
||||
create_messages_for_conversation(conversation)
|
||||
resolve_conversation_if_needed(conversation)
|
||||
|
||||
# Determine if should resolve but don't update yet
|
||||
should_resolve = rand > 0.3
|
||||
if should_resolve
|
||||
resolution_delay = rand((30.minutes)..(24.hours))
|
||||
resolution_time = created_at + resolution_delay
|
||||
end
|
||||
end
|
||||
|
||||
travel_back
|
||||
end
|
||||
|
||||
# Now resolve outside of time travel if needed
|
||||
if should_resolve && resolution_time
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_column(:status, :resolved)
|
||||
conversation.update_column(:updated_at, resolution_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
# Trigger the event with proper timestamp
|
||||
travel_to(resolution_time) do
|
||||
trigger_conversation_resolved_event(conversation)
|
||||
end
|
||||
travel_back
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
private
|
||||
|
||||
@@ -85,16 +109,6 @@ class Seeders::Reports::ConversationCreator
|
||||
message_creator.create_messages
|
||||
end
|
||||
|
||||
def resolve_conversation_if_needed(conversation)
|
||||
return unless rand < 0.7
|
||||
|
||||
resolution_delay = rand((30.minutes)..(24.hours))
|
||||
travel(resolution_delay)
|
||||
conversation.update!(status: :resolved)
|
||||
|
||||
trigger_conversation_resolved_event(conversation)
|
||||
end
|
||||
|
||||
def trigger_conversation_resolved_event(conversation)
|
||||
event_data = { conversation: conversation }
|
||||
|
||||
|
||||
+23
-4
@@ -31,14 +31,33 @@ class Webhooks::Trigger
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
return unless should_handle_error?
|
||||
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||
return unless message
|
||||
|
||||
update_message_status(error)
|
||||
case @webhook_type
|
||||
when :agent_bot_webhook
|
||||
conversation = message.conversation
|
||||
return unless conversation&.pending?
|
||||
|
||||
conversation.open!
|
||||
create_agent_bot_error_activity(conversation)
|
||||
when :api_inbox_webhook
|
||||
update_message_status(error)
|
||||
end
|
||||
end
|
||||
|
||||
def should_handle_error?
|
||||
@webhook_type == :api_inbox_webhook && SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||
def create_agent_bot_error_activity(conversation)
|
||||
content = I18n.t('conversations.activity.agent_bot.error_moved_to_open')
|
||||
Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(conversation, content))
|
||||
end
|
||||
|
||||
def activity_message_params(conversation, content)
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
|
||||
def update_message_status(error)
|
||||
|
||||
+1
-1
@@ -79,8 +79,8 @@
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"posthog-js": "^1.260.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Bulk reindex all messages with throttling to prevent DB overload
|
||||
# This creates jobs slowly to avoid overwhelming the database connection pool
|
||||
# Usage: RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb
|
||||
|
||||
JOBS_PER_MINUTE = 50 # Adjust based on your DB capacity
|
||||
BATCH_SIZE = 1000 # Messages per job
|
||||
|
||||
batch_count = 0
|
||||
total_batches = (Message.count / BATCH_SIZE.to_f).ceil
|
||||
start_time = Time.zone.now
|
||||
|
||||
index_name = Message.searchkick_index.name
|
||||
|
||||
puts '=' * 80
|
||||
puts "Bulk Reindex Started at #{start_time}"
|
||||
puts '=' * 80
|
||||
puts "Total messages: #{Message.count}"
|
||||
puts "Batch size: #{BATCH_SIZE}"
|
||||
puts "Total batches: #{total_batches}"
|
||||
puts "Index name: #{index_name}"
|
||||
puts "Rate: #{JOBS_PER_MINUTE} jobs/minute (#{JOBS_PER_MINUTE * BATCH_SIZE} messages/minute)"
|
||||
puts "Estimated time: #{(total_batches / JOBS_PER_MINUTE.to_f / 60).round(2)} hours"
|
||||
puts '=' * 80
|
||||
puts ''
|
||||
|
||||
sleep(15)
|
||||
|
||||
Message.find_in_batches(batch_size: BATCH_SIZE).with_index do |batch, index|
|
||||
batch_count += 1
|
||||
|
||||
# Enqueue to low priority queue with proper format
|
||||
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
|
||||
class_name: 'Message',
|
||||
index_name: index_name,
|
||||
batch_id: index,
|
||||
record_ids: batch.map(&:id) # Keep as integers like Message.reindex does
|
||||
)
|
||||
|
||||
# Throttle: wait after every N jobs
|
||||
if (batch_count % JOBS_PER_MINUTE).zero?
|
||||
elapsed = Time.zone.now - start_time
|
||||
progress = (batch_count.to_f / total_batches * 100).round(2)
|
||||
queue_size = Sidekiq::Queue.new('bulk_reindex_low').size
|
||||
|
||||
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}] Progress: #{batch_count}/#{total_batches} (#{progress}%)"
|
||||
puts " Queue size: #{queue_size}"
|
||||
puts " Elapsed: #{(elapsed / 3600).round(2)} hours"
|
||||
puts " ETA: #{((elapsed / batch_count * (total_batches - batch_count)) / 3600).round(2)} hours remaining"
|
||||
puts ''
|
||||
|
||||
sleep(60)
|
||||
end
|
||||
end
|
||||
|
||||
puts '=' * 80
|
||||
puts "Done! Created #{batch_count} jobs"
|
||||
puts "Total time: #{((Time.zone.now - start_time) / 3600).round(2)} hours"
|
||||
puts '=' * 80
|
||||
@@ -0,0 +1,19 @@
|
||||
# Monitor bulk reindex progress
|
||||
# RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
|
||||
|
||||
puts 'Monitoring bulk reindex progress (Ctrl+C to stop)...'
|
||||
puts ''
|
||||
|
||||
loop do
|
||||
bulk_queue = Sidekiq::Queue.new('bulk_reindex_low')
|
||||
prod_queue = Sidekiq::Queue.new('async_database_migration')
|
||||
retry_set = Sidekiq::RetrySet.new
|
||||
|
||||
puts "[#{Time.zone.now.strftime('%Y-%m-%d %H:%M:%S')}]"
|
||||
puts " Bulk Reindex Queue: #{bulk_queue.size} jobs"
|
||||
puts " Production Queue: #{prod_queue.size} jobs"
|
||||
puts " Retry Queue: #{retry_set.size} jobs"
|
||||
puts " #{('-' * 60)}"
|
||||
|
||||
sleep(30)
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# Reindex messages for a single account
|
||||
# Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]
|
||||
|
||||
#account_id = ARGV[0]&.to_i
|
||||
days_back = (ARGV[1] || 30).to_i
|
||||
|
||||
# if account_id.nil? || account_id.zero?
|
||||
# puts "Usage: bundle exec rails runner script/reindex_single_account.rb ACCOUNT_ID [DAYS_BACK]"
|
||||
# puts "Example: bundle exec rails runner script/reindex_single_account.rb 93293 30"
|
||||
# exit 1
|
||||
# end
|
||||
|
||||
# account = Account.find(account_id)
|
||||
# puts "=" * 80
|
||||
# puts "Reindexing messages for: #{account.name} (ID: #{account.id})"
|
||||
# puts "=" * 80
|
||||
|
||||
# Enable feature if not already enabled
|
||||
# unless account.feature_enabled?('advanced_search_indexing')
|
||||
# puts "Enabling advanced_search_indexing feature..."
|
||||
# account.enable_features(:advanced_search_indexing)
|
||||
# account.save!
|
||||
# end
|
||||
|
||||
# Get messages to index
|
||||
# messages = Message.where(account_id: account.id)
|
||||
# .where(message_type: [0, 1]) # incoming/outgoing only
|
||||
# .where('created_at >= ?', days_back.days.ago)
|
||||
|
||||
messages = Message.where('created_at >= ?', days_back.days.ago)
|
||||
|
||||
puts "Found #{messages.count} messages to index (last #{days_back} days)"
|
||||
puts ''
|
||||
|
||||
sleep(15)
|
||||
|
||||
# Create bulk reindex jobs
|
||||
index_name = Message.searchkick_index.name
|
||||
batch_count = 0
|
||||
|
||||
messages.find_in_batches(batch_size: 1000).with_index do |batch, index|
|
||||
Searchkick::BulkReindexJob.set(queue: :bulk_reindex_low).perform_later(
|
||||
class_name: 'Message',
|
||||
index_name: index_name,
|
||||
batch_id: index,
|
||||
record_ids: batch.map(&:id)
|
||||
)
|
||||
|
||||
batch_count += 1
|
||||
print '.'
|
||||
sleep(0.5) # Small delay
|
||||
end
|
||||
|
||||
puts ''
|
||||
puts '=' * 80
|
||||
puts "Done! Created #{batch_count} bulk reindex jobs"
|
||||
puts 'Messages will be indexed shortly via the bulk_reindex_low queue'
|
||||
puts '=' * 80
|
||||
@@ -1,16 +1,35 @@
|
||||
require 'rails_helper'
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
|
||||
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
describe 'POST /enterprise/webhooks/stripe' do
|
||||
let(:params) { { content: 'hello' } }
|
||||
|
||||
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
|
||||
it 'delegates to the v1 handler for legacy events' do
|
||||
handle_stripe = double
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
|
||||
event = double('Stripe::Event', type: 'invoice.created')
|
||||
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
|
||||
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
|
||||
allow(handle_stripe).to receive(:perform)
|
||||
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
expect(handle_stripe).to have_received(:perform).with(event: params)
|
||||
|
||||
expect(handle_stripe).to have_received(:perform).with(event: event)
|
||||
end
|
||||
|
||||
it 'delegates v2 billing events to the v2 webhook handler' do
|
||||
event = double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated')
|
||||
handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, perform: { success: true })
|
||||
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
|
||||
allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).and_return(handler_double)
|
||||
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
|
||||
expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new)
|
||||
expect(handler_double).to have_received(:perform).with(event: event)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are missing' do
|
||||
@@ -24,3 +43,4 @@ RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
|
||||
@@ -12,7 +12,7 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
|
||||
.on_queue('default')
|
||||
end
|
||||
|
||||
it 'executes perform' do
|
||||
it 'uses V1 customer creation service' do
|
||||
create_stripe_customer_service = double
|
||||
allow(Enterprise::Billing::CreateStripeCustomerService)
|
||||
.to receive(:new)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Ai::CaptainCreditService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).and_return(credit_service)
|
||||
end
|
||||
|
||||
describe '#check_and_use_credits' do
|
||||
context 'when account is not on v2 billing' do
|
||||
it 'returns success without invoking credit service' do
|
||||
result = service.check_and_use_credits
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account uses v2 billing' do
|
||||
before do
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2))
|
||||
allow(credit_service).to receive(:use_credit).and_return(success: true)
|
||||
end
|
||||
|
||||
it 'delegates to credit management service with metadata' do
|
||||
metadata = { 'feature' => 'ai_reply', 'conversation_id' => 123 }
|
||||
|
||||
result = service.check_and_use_credits(feature: 'ai_reply', metadata: metadata)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(credit_service).to have_received(:use_credit)
|
||||
.with(feature: 'ai_reply', amount: 1, metadata: metadata)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+15
-115
@@ -5,137 +5,37 @@ describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
||||
let(:admin2) { create(:user, account: account, role: :administrator) }
|
||||
let(:subscriptions_list) { double }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not call stripe methods if customer id is present' do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
|
||||
allow(subscriptions_list).to receive(:data).and_return([])
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
{
|
||||
stripe_customer_id: 'cus_random_number',
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
|
||||
it 'calls stripe methods to create a customer and updates the account' do
|
||||
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(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
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 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
{
|
||||
stripe_customer_id: customer.id,
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when checking for existing subscriptions' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account has no stripe_customer_id' do
|
||||
it 'creates a new subscription' do
|
||||
context 'when customer does not exist' do
|
||||
it 'creates a stripe customer and saves the customer_id' do
|
||||
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(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:create)
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
{
|
||||
stripe_customer_id: 'cus_random_number'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has stripe_customer_id' do
|
||||
let(:stripe_customer_id) { 'cus_random_number' }
|
||||
|
||||
context 'when customer already exists' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_customer' })
|
||||
end
|
||||
|
||||
context 'when customer has active subscriptions' do
|
||||
before do
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
end
|
||||
it 'does not create a new customer' do
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
|
||||
it 'does not create a new subscription' do
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Subscription).not_to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:list).with(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
)
|
||||
end
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_customer')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -317,4 +317,160 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'credit grant handling' do
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
|
||||
.with(account: account).and_return(credit_service)
|
||||
end
|
||||
|
||||
context 'when handling monthly credit grant' do
|
||||
it 'syncs monthly credits from Stripe' do
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 2000)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v1/billing/credit_grants/credgr_test_123')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_credits).with(2000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling topup credit grant' do
|
||||
it 'adds topup credits' do
|
||||
allow(credit_service).to receive(:add_topup_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 500)
|
||||
),
|
||||
expires_at: nil
|
||||
)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v1/billing/credit_grants/credgr_test_456')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:add_topup_credits).with(500)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling credit grant update with expiration' do
|
||||
it 'expires monthly credits when grant is expired' do
|
||||
allow(credit_service).to receive(:expire_monthly_credits).and_return(100)
|
||||
|
||||
grant = OpenStruct.new(
|
||||
customer: 'cus_123',
|
||||
expired_at: Time.current
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.updated')
|
||||
allow(data).to receive(:object).and_return(grant)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:expire_monthly_credits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling monetary type credit grant' do
|
||||
it 'syncs monthly credits from monetary grant' do
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with monetary amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'monetary',
|
||||
monetary: OpenStruct.new(
|
||||
currency: 'usd',
|
||||
value: 1000
|
||||
)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v1/billing/credit_grants/credgr_test_monetary')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_credits).with(1000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling credit grant with zero amount' do
|
||||
it 'does not call credit service' do
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with zero amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 0)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v1/billing/credit_grants/credgr_test_zero')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
# Ensure we don't accidentally call these methods
|
||||
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::V2::CreditManagementService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'monthly_credits' => 100,
|
||||
'topup_credits' => 50,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
}
|
||||
)
|
||||
|
||||
# Stub Stripe meter event creation
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(
|
||||
OpenStruct.new(identifier: 'test_event_123')
|
||||
)
|
||||
end
|
||||
|
||||
describe '#total_credits' do
|
||||
it 'returns sum of monthly and topup credits' do
|
||||
expect(service.total_credits).to eq(150)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#use_credit' do
|
||||
context 'when sufficient credits' do
|
||||
it 'uses credits and reports to Stripe' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 10)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:credits_used]).to eq(10)
|
||||
expect(result[:remaining]).to eq(140)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(90)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(50)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when insufficient credits' do
|
||||
it 'returns error' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 200)
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to eq('Insufficient credits')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using mixed credits' do
|
||||
it 'uses monthly first then topup' do
|
||||
result = service.use_credit(feature: 'ai_test', amount: 120)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
expect(account.custom_attributes['topup_credits']).to eq(30)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_monthly_credits' do
|
||||
it 'updates monthly credits from Stripe' do
|
||||
service.sync_monthly_credits(500)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(500)
|
||||
expect(account.credit_transactions.last.description).to eq('Monthly credits from Stripe')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expire_monthly_credits' do
|
||||
it 'expires monthly credits' do
|
||||
expired = service.expire_monthly_credits
|
||||
|
||||
expect(expired).to eq(100)
|
||||
account.reload
|
||||
expect(account.custom_attributes['monthly_credits']).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_topup_credits' do
|
||||
it 'adds topup credits' do
|
||||
service.add_topup_credits(100)
|
||||
|
||||
account.reload
|
||||
expect(account.custom_attributes['topup_credits']).to eq(150)
|
||||
expect(account.credit_transactions.last.description).to eq('Topup credits added')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Billing::V2::PricingPlanService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
|
||||
end
|
||||
|
||||
describe '#create_custom_pricing_unit' do
|
||||
it 'creates a custom pricing unit' do
|
||||
cpu_response = OpenStruct.new(id: 'cpu_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(cpu_response)
|
||||
|
||||
result = service.create_custom_pricing_unit(display_name: 'Credits', lookup_key: 'credits_001')
|
||||
|
||||
expect(result.id).to eq('cpu_123')
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:post,
|
||||
'/v2/billing/custom_pricing_units',
|
||||
{ display_name: 'Credits', lookup_key: 'credits_001' },
|
||||
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_meter' do
|
||||
it 'creates a billing meter' do
|
||||
meter_response = OpenStruct.new(id: 'meter_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(meter_response)
|
||||
|
||||
result = service.create_meter(display_name: 'Prompts', event_name: 'prompts_001')
|
||||
|
||||
expect(result.id).to eq('meter_123')
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:post,
|
||||
'/v1/billing/meters',
|
||||
kind_of(Hash),
|
||||
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_pricing_plan' do
|
||||
it 'creates a pricing plan' do
|
||||
plan_response = OpenStruct.new(id: 'plan_123')
|
||||
allow(StripeV2Client).to receive(:request).and_return(plan_response)
|
||||
|
||||
result = service.create_pricing_plan(display_name: 'Business Plan', lookup_key: 'business_plan')
|
||||
|
||||
expect(result.id).to eq('plan_123')
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:post,
|
||||
'/v2/billing/pricing_plans',
|
||||
{ display_name: 'Business Plan', currency: 'usd', tax_behavior: 'exclusive', lookup_key: 'business_plan' },
|
||||
{ api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_complete_pricing_plan' do
|
||||
# Clean up any existing InstallationConfig entries before each test
|
||||
before do
|
||||
InstallationConfig.where(name: %w[STRIPE_CUSTOM_PRICING_UNIT_ID STRIPE_METER_ID]).destroy_all
|
||||
# Also ensure ENV doesn't have STRIPE_METER_ID
|
||||
allow(ENV).to receive(:fetch).with('STRIPE_METER_ID', nil).and_return(nil)
|
||||
end
|
||||
|
||||
let(:cpu) { OpenStruct.new(id: 'cpu_123') }
|
||||
let(:meter) { OpenStruct.new(id: 'meter_123') }
|
||||
let(:plan) { OpenStruct.new(id: 'plan_123') }
|
||||
let(:service_action) { OpenStruct.new(id: 'sa_123') }
|
||||
let(:rate_card) { OpenStruct.new(id: 'rc_123', latest_version: 'v1') }
|
||||
let(:live_plan) { OpenStruct.new(id: 'plan_123', live_version: 'latest') }
|
||||
|
||||
let(:config) do
|
||||
{
|
||||
# Shared resources
|
||||
cpu_display_name: 'Credits',
|
||||
cpu_lookup_key: 'cpu_001',
|
||||
meter_display_name: 'Prompts',
|
||||
meter_event_name: 'prompts_001',
|
||||
|
||||
# Plan details
|
||||
plan_display_name: 'Business Plan',
|
||||
plan_lookup_key: 'business_plan',
|
||||
|
||||
# Component-specific lookup keys (each must be unique)
|
||||
lookup_key: 'chatwoot_business_agents', # Used for license fee
|
||||
service_action_lookup_key: 'chatwoot_business_credits',
|
||||
metered_item_lookup_key: 'chatwoot_business_usage',
|
||||
|
||||
# License fee
|
||||
licensed_item_display_name: 'Seat',
|
||||
licensed_item_unit_label: 'per agent',
|
||||
license_fee_display_name: 'Fee',
|
||||
license_fee_amount: 3900,
|
||||
|
||||
# Service action (monthly credits)
|
||||
monthly_credit_amount: 2000,
|
||||
|
||||
# Rate card (overage)
|
||||
rate_card_display_name: 'Rates',
|
||||
metered_item_display_name: 'Prompt',
|
||||
rate_value: 1
|
||||
}
|
||||
end
|
||||
|
||||
def stub_stripe_client
|
||||
allow(StripeV2Client).to receive(:request) do |_method, path, *_args|
|
||||
case path
|
||||
when '/v2/billing/custom_pricing_units' then cpu
|
||||
when '/v1/billing/meters' then meter
|
||||
when '/v2/billing/pricing_plans' then plan
|
||||
when '/v2/billing/pricing_plans/plan_123' then live_plan
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stub_component_builder
|
||||
component_builder = instance_double(Enterprise::Billing::V2::PricingPlanComponentBuilder)
|
||||
allow(component_builder).to receive(:add_license_fee_component).and_return(true)
|
||||
allow(component_builder).to receive(:add_service_action_component).and_return(service_action)
|
||||
allow(component_builder).to receive(:add_rate_card_component).and_return(rate_card)
|
||||
allow(service).to receive(:component_builder).and_return(component_builder)
|
||||
end
|
||||
|
||||
it 'creates a complete pricing plan with all components' do
|
||||
stub_stripe_client
|
||||
stub_component_builder
|
||||
|
||||
result = service.create_complete_pricing_plan(config)
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:pricing_plan]).to eq(plan)
|
||||
expect(result[:custom_pricing_unit]).to eq(cpu)
|
||||
expect(result[:meter]).to eq(meter)
|
||||
expect(result[:service_action]).to eq(service_action)
|
||||
expect(InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value).to eq('cpu_123')
|
||||
expect(InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value).to eq('meter_123')
|
||||
end
|
||||
end
|
||||
end
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::V2::SubscriptionProvisioningService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:subscription_id) { 'bpps_subscription_123' }
|
||||
let(:pricing_plan_id) { 'bpp_business_plan_123' }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
|
||||
end
|
||||
|
||||
describe '#provision' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: subscription_id,
|
||||
pricing_plan: pricing_plan_id,
|
||||
component_values: [
|
||||
{ 'type' => 'license_fee', 'quantity' => 5 }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(StripeV2Client).to receive(:request).and_return(subscription_response)
|
||||
end
|
||||
|
||||
it 'retrieves subscription details from Stripe' do
|
||||
service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(StripeV2Client).to have_received(:request).with(
|
||||
:get,
|
||||
"/v2/billing/pricing_plan_subscriptions/#{subscription_id}",
|
||||
{},
|
||||
hash_including(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview')
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates account custom attributes with subscription details' do
|
||||
result = service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(account.custom_attributes['stripe_billing_version']).to eq(2)
|
||||
expect(account.custom_attributes['stripe_subscription_id']).to eq(subscription_id)
|
||||
expect(account.custom_attributes['stripe_pricing_plan_id']).to eq(pricing_plan_id)
|
||||
expect(account.custom_attributes['subscribed_quantity']).to eq(5)
|
||||
expect(account.custom_attributes['subscription_status']).to eq('active')
|
||||
expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business')
|
||||
end
|
||||
|
||||
it 'syncs monthly credits based on plan' do
|
||||
credit_service = instance_double(Enterprise::Billing::V2::CreditManagementService)
|
||||
allow(Enterprise::Billing::V2::CreditManagementService)
|
||||
.to receive(:new)
|
||||
.with(account: account)
|
||||
.and_return(credit_service)
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
|
||||
service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_credits).with(50_000)
|
||||
end
|
||||
|
||||
it 'enables plan-specific features' do
|
||||
service.provision(subscription_id: subscription_id)
|
||||
|
||||
expected_features = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
sla
|
||||
custom_roles
|
||||
]
|
||||
enabled_feature_names = account.enabled_features.map(&:first)
|
||||
expect(enabled_feature_names).to match_array(expected_features)
|
||||
end
|
||||
|
||||
it 'returns success response with subscription details' do
|
||||
result = service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:subscription_id]).to eq(subscription_id)
|
||||
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
|
||||
expect(result[:quantity]).to eq(5)
|
||||
expect(result[:message]).to eq('Subscription provisioned successfully')
|
||||
end
|
||||
|
||||
context 'when pricing plan has no license fee component' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: subscription_id,
|
||||
pricing_plan: pricing_plan_id,
|
||||
component_values: []
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Clear pending_subscription_quantity to test default behavior
|
||||
account.update!(custom_attributes: account.custom_attributes.except('pending_subscription_quantity'))
|
||||
end
|
||||
|
||||
it 'defaults quantity to 1' do
|
||||
result = service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(result[:quantity]).to eq(1)
|
||||
expect(account.custom_attributes['subscribed_quantity']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Stripe API returns an error' do
|
||||
before do
|
||||
allow(StripeV2Client).to receive(:request).and_raise(Stripe::StripeError.new('API error'))
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
result = service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to include('Stripe error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an unexpected error occurs' do
|
||||
before do
|
||||
allow(StripeV2Client).to receive(:request).and_raise(StandardError.new('Unexpected error'))
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
result = service.provision(subscription_id: subscription_id)
|
||||
|
||||
expect(result[:success]).to be(false)
|
||||
expect(result[:message]).to include('Provisioning error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Startup plan' do
|
||||
let(:startup_plan_id) { 'bpp_startup_plan_123' }
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: subscription_id,
|
||||
pricing_plan: startup_plan_id,
|
||||
component_values: [{ 'type' => 'license_fee', 'quantity' => 3 }]
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'STRIPE_STARTUP_PLAN_ID', value: startup_plan_id)
|
||||
end
|
||||
|
||||
it 'enables only Startup features' do
|
||||
service.provision(subscription_id: subscription_id)
|
||||
|
||||
expected_features = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
]
|
||||
enabled_feature_names = account.enabled_features.map(&:first)
|
||||
expect(enabled_feature_names).to match_array(expected_features)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with Enterprise plan' do
|
||||
let(:enterprise_plan_id) { 'bpp_enterprise_plan_123' }
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: subscription_id,
|
||||
pricing_plan: enterprise_plan_id,
|
||||
component_values: [{ 'type' => 'license_fee', 'quantity' => 10 }]
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'STRIPE_ENTERPRISE_PLAN_ID', value: enterprise_plan_id)
|
||||
end
|
||||
|
||||
it 'enables all features including Enterprise-specific ones' do
|
||||
service.provision(subscription_id: subscription_id)
|
||||
|
||||
expected_features = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
sla
|
||||
custom_roles
|
||||
audit_logs
|
||||
disable_branding
|
||||
saml
|
||||
]
|
||||
enabled_feature_names = account.enabled_features.map(&:first)
|
||||
expect(enabled_feature_names).to match_array(expected_features)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
require 'rails_helper'
|
||||
require 'ostruct'
|
||||
|
||||
describe Enterprise::Billing::V2::TopupService do
|
||||
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
let(:invoice) { Stripe::Invoice.construct_from(id: 'in_123', customer: 'cus_123', currency: 'usd') }
|
||||
let(:invoice_item) do
|
||||
Stripe::InvoiceItem.construct_from(
|
||||
id: 'ii_123',
|
||||
invoice: 'in_123',
|
||||
amount: 5000,
|
||||
currency: 'usd'
|
||||
)
|
||||
end
|
||||
let(:finalized_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'open') }
|
||||
let(:paid_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'paid') }
|
||||
let(:credit_grant) do
|
||||
{
|
||||
'id' => 'credgr_123',
|
||||
'customer' => 'cus_123',
|
||||
'amount' => { 'type' => 'monetary', 'monetary' => { 'value' => 5000, 'currency' => 'usd' } }
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(500).and_return(
|
||||
credits: 500,
|
||||
amount: 50.0,
|
||||
currency: 'usd'
|
||||
)
|
||||
|
||||
# Mock Stripe Invoice creation
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(invoice)
|
||||
|
||||
# Mock Stripe InvoiceItem creation
|
||||
allow(Stripe::InvoiceItem).to receive(:create).and_return(invoice_item)
|
||||
|
||||
# Mock Stripe Invoice finalization
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(finalized_invoice)
|
||||
|
||||
# Mock Stripe Invoice payment
|
||||
allow(Stripe::Invoice).to receive(:pay).and_return(paid_invoice)
|
||||
|
||||
# Mock Stripe Credit Grant creation (monetary amount)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return(credit_grant)
|
||||
end
|
||||
|
||||
describe '#create_topup' do
|
||||
context 'when successful' do
|
||||
before do
|
||||
service.create_topup(credits: 500)
|
||||
end
|
||||
|
||||
it 'creates invoice with correct parameters' do
|
||||
expect(Stripe::Invoice).to have_received(:create).with(
|
||||
hash_including(
|
||||
customer: 'cus_123',
|
||||
currency: 'usd',
|
||||
collection_method: 'charge_automatically'
|
||||
),
|
||||
hash_including(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates invoice item with topup amount' do
|
||||
expect(Stripe::InvoiceItem).to have_received(:create).with(
|
||||
hash_including(
|
||||
customer: 'cus_123',
|
||||
amount: 5000,
|
||||
currency: 'usd',
|
||||
invoice: 'in_123',
|
||||
description: 'Credit Topup: 500 credits'
|
||||
),
|
||||
hash_including(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
it 'finalizes invoice for payment' do
|
||||
expect(Stripe::Invoice).to have_received(:finalize_invoice).with(
|
||||
'in_123',
|
||||
hash_including(auto_advance: false),
|
||||
hash_including(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
it 'pays the invoice explicitly' do
|
||||
expect(Stripe::Invoice).to have_received(:pay).with(
|
||||
'in_123',
|
||||
{},
|
||||
hash_including(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates credit grant with monetary amount' do
|
||||
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
|
||||
hash_including(
|
||||
customer: 'cus_123',
|
||||
name: 'Topup: 500 credits',
|
||||
amount: hash_including(
|
||||
type: 'monetary',
|
||||
monetary: hash_including(currency: 'usd', value: 5000)
|
||||
),
|
||||
applicability_config: hash_including(scope: hash_including(price_type: 'metered')),
|
||||
category: 'paid',
|
||||
metadata: hash_including(
|
||||
account_id: account.id.to_s,
|
||||
source: 'topup',
|
||||
credits: '500'
|
||||
)
|
||||
),
|
||||
hash_including(:api_key)
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success response with all ids' do
|
||||
result = service.create_topup(credits: 500)
|
||||
|
||||
expect(result[:success]).to be true
|
||||
expect(result[:credits]).to eq(500)
|
||||
expect(result[:invoice_id]).to eq('in_123')
|
||||
expect(result[:credit_grant_id]).to eq('credgr_123')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns error when option missing' do
|
||||
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).and_return(nil)
|
||||
|
||||
result = service.create_topup(credits: 999)
|
||||
|
||||
expect(result[:success]).to be false
|
||||
expect(result[:message]).to eq('Unsupported topup amount')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::V2::UsageReporterService do
|
||||
let(:account) { create(:account) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'stripe_billing_version' => 2,
|
||||
'stripe_customer_id' => 'cus_test_123',
|
||||
'stripe_meter_event_name' => 'ai_prompts'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'posts usage events to Stripe meters' do
|
||||
meter_event = OpenStruct.new(identifier: 'me_test_123')
|
||||
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(meter_event)
|
||||
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: true, event_id: 'me_test_123')
|
||||
expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options|
|
||||
expect(params[:event_name]).to eq('ai_prompts')
|
||||
expect(params[:payload][:value]).to eq('5')
|
||||
expect(params[:payload][:stripe_customer_id]).to eq('cus_test_123')
|
||||
expect(options[:stripe_version]).to eq('2025-08-27.preview')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when V2 billing not enabled' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 1 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'V2 billing not enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no Stripe customer' do
|
||||
before do
|
||||
account.update!(custom_attributes: { 'stripe_billing_version' => 2 })
|
||||
end
|
||||
|
||||
it 'returns error' do
|
||||
result = service.report(5, 'ai_test')
|
||||
|
||||
expect(result).to include(success: false, message: 'Missing Stripe configuration')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
require 'rails_helper'
|
||||
# rubocop:disable RSpec/VerifiedDoubles
|
||||
|
||||
describe Enterprise::Billing::V2::WebhookHandlerService do
|
||||
let(:account) do
|
||||
create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 })
|
||||
end
|
||||
let(:service) { described_class.new }
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service)
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when handling subscription servicing activated' do
|
||||
let(:subscription_response) do
|
||||
OpenStruct.new(
|
||||
id: 'bpps_subscription_123',
|
||||
pricing_plan: 'bpp_business_plan_123',
|
||||
billing_cadence: 'cadence_123',
|
||||
component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }]
|
||||
)
|
||||
end
|
||||
|
||||
let(:cadence_response) do
|
||||
OpenStruct.new(
|
||||
id: 'cadence_123',
|
||||
payer: OpenStruct.new(customer: 'cus_123')
|
||||
)
|
||||
end
|
||||
|
||||
let(:provisioning_response) do
|
||||
{
|
||||
success: true,
|
||||
subscription_id: 'bpps_subscription_123',
|
||||
pricing_plan_id: 'bpp_business_plan_123',
|
||||
quantity: 5
|
||||
}
|
||||
end
|
||||
|
||||
let(:event) do
|
||||
double(
|
||||
'Stripe::Event',
|
||||
type: 'v2.billing.pricing_plan_subscription.servicing_activated',
|
||||
related_object: OpenStruct.new(id: 'bpps_subscription_123')
|
||||
)
|
||||
end
|
||||
|
||||
let(:expected_features) do
|
||||
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook
|
||||
channel_email channel_instagram captain_integration advanced_search_indexing sla custom_roles]
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123')
|
||||
|
||||
# Mock account lookup via subscription and cadence
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
|
||||
# Mock provisioning service
|
||||
provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService)
|
||||
allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service)
|
||||
allow(provisioning_service).to receive(:provision).and_return(provisioning_response)
|
||||
|
||||
allow(credit_service).to receive(:sync_monthly_credits)
|
||||
end
|
||||
|
||||
it 'delegates to subscription provisioning service' do
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}"
|
||||
expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling unknown event' do
|
||||
it 'returns success' do
|
||||
related_object = OpenStruct.new(id: 'bpps_subscription_123')
|
||||
event = double('Stripe::Event', type: 'unknown.event', related_object: related_object)
|
||||
|
||||
# Mock account lookup
|
||||
subscription_response = OpenStruct.new(billing_cadence: 'cadence_123')
|
||||
cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123'))
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything)
|
||||
.and_return(subscription_response)
|
||||
allow(StripeV2Client).to receive(:request)
|
||||
.with(:get, '/v2/billing/cadences/cadence_123', anything, anything)
|
||||
.and_return(cadence_response)
|
||||
|
||||
result = service.perform(event: event)
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable RSpec/VerifiedDoubles
|
||||
@@ -1,6 +1,8 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Webhooks::Trigger do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
subject(:trigger) { described_class }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
@@ -8,8 +10,18 @@ describe Webhooks::Trigger do
|
||||
let!(:conversation) { create(:conversation, inbox: inbox) }
|
||||
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
|
||||
|
||||
let!(:webhook_type) { :api_inbox_webhook }
|
||||
let(:webhook_type) { :api_inbox_webhook }
|
||||
let!(:url) { 'https://test.com' }
|
||||
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
|
||||
|
||||
before do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
end
|
||||
|
||||
after do
|
||||
clear_enqueued_jobs
|
||||
clear_performed_jobs
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
it 'triggers webhook' do
|
||||
@@ -54,6 +66,57 @@ describe Webhooks::Trigger do
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||
end
|
||||
|
||||
context 'when webhook type is agent bot' do
|
||||
let(:webhook_type) { :agent_bot_webhook }
|
||||
|
||||
it 'reopens conversation and enqueues activity message if pending' do
|
||||
conversation.update(status: :pending)
|
||||
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end.not_to(change { message.reload.status })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
|
||||
activity_message = conversation.reload.messages.order(:created_at).last
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to eq(agent_bot_error_content)
|
||||
end
|
||||
|
||||
it 'does not change message status or enqueue activity when conversation is not pending' do
|
||||
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: 5
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end.not_to(change { message.reload.status })
|
||||
|
||||
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not update message status if webhook fails for other events' do
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ApplicationRecord do
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_email,
|
||||
attribute: :smtp_password,
|
||||
value: 'smtp-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_email,
|
||||
attribute: :imap_password,
|
||||
value: 'imap-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_twilio_sms,
|
||||
attribute: :auth_token,
|
||||
value: 'twilio-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :integrations_hook,
|
||||
attribute: :access_token,
|
||||
value: 'hook-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_facebook_page,
|
||||
attribute: :page_access_token,
|
||||
value: 'fb-page-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_facebook_page,
|
||||
attribute: :user_access_token,
|
||||
value: 'fb-user-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_instagram,
|
||||
attribute: :access_token,
|
||||
value: 'ig-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_line,
|
||||
attribute: :line_channel_secret,
|
||||
value: 'line-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_line,
|
||||
attribute: :line_channel_token,
|
||||
value: 'line-token-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_telegram,
|
||||
attribute: :bot_token,
|
||||
value: 'telegram-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_twitter_profile,
|
||||
attribute: :twitter_access_token,
|
||||
value: 'twitter-access-secret'
|
||||
|
||||
it_behaves_like 'encrypted external credential',
|
||||
factory: :channel_twitter_profile,
|
||||
attribute: :twitter_access_token_secret,
|
||||
value: 'twitter-secret-secret'
|
||||
|
||||
context 'when backfilling legacy plaintext' do
|
||||
before do
|
||||
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
it 'reads existing plaintext and encrypts on update' do
|
||||
account = create(:account)
|
||||
channel = create(:channel_email, account: account, smtp_password: nil)
|
||||
|
||||
# Simulate legacy plaintext by updating the DB directly
|
||||
sql = ActiveRecord::Base.send(
|
||||
:sanitize_sql_array,
|
||||
['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
|
||||
)
|
||||
ActiveRecord::Base.connection.execute(sql)
|
||||
|
||||
legacy_record = Channel::Email.find(channel.id)
|
||||
expect(legacy_record.smtp_password).to eq('legacy-plain')
|
||||
|
||||
legacy_record.update!(smtp_password: 'encrypted-now')
|
||||
|
||||
stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
|
||||
expect(stored_value).to be_present
|
||||
expect(stored_value).not_to include('encrypted-now')
|
||||
expect(legacy_record.smtp_password).to eq('encrypted-now')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when looking up telegram legacy records' do
|
||||
before do
|
||||
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
it 'finds plaintext records via fallback lookup' do
|
||||
channel = create(:channel_telegram, bot_token: 'legacy-token')
|
||||
|
||||
# Simulate legacy plaintext by updating the DB directly
|
||||
sql = ActiveRecord::Base.send(
|
||||
:sanitize_sql_array,
|
||||
['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
|
||||
)
|
||||
ActiveRecord::Base.connection.execute(sql)
|
||||
|
||||
found = Channel::Telegram.find_by(bot_token: 'legacy-token')
|
||||
expect(found).to eq(channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'|
|
||||
before do
|
||||
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
|
||||
if defined?(Facebook::Messenger::Subscriptions)
|
||||
allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true)
|
||||
allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true)
|
||||
end
|
||||
end
|
||||
|
||||
it "encrypts #{attribute} at rest" do
|
||||
record = create(factory, attribute => value)
|
||||
|
||||
raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s
|
||||
expect(raw_stored_value).to be_present
|
||||
expect(raw_stored_value).not_to include(value)
|
||||
expect(record.public_send(attribute)).to eq(value)
|
||||
expect(record.encrypted_attribute?(attribute)).to be(true)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user