Compare commits

...
Author SHA1 Message Date
Tanmay Sharma 45af6266bb billing V2 frontend 2025-10-27 14:18:25 +05:30
Tanmay Sharma 3ba4340042 remove update subscription flow 2025-10-27 14:16:39 +05:30
Tanmay Sharma 411048715d self review backend changes 2025-10-27 02:17:43 +05:30
Tanmay Sharma 9a7a2486e4 Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2 2025-10-27 00:43:44 +05:30
Tanmay Sharma 39af360a18 implement checkout endpoint for subscription in a better way 2025-10-27 00:28:52 +05:30
Tanmay Sharma ac1743514b use env variable for stripe v2 2025-10-16 16:37:52 +05:30
Sivin VargheseandGitHub c1df37b41d Merge branch 'develop' into feature/stripe_v2 2025-10-14 19:23:56 +05:30
Tanmay Sharma 7c3b07c507 code cleanup 2025-10-13 21:06:05 +05:30
f1f1ce644c feat: Overview heatmap improvements (#12359)
This PR adds inbox filtering to the conversation traffic heatmap,
allowing users to analyze patterns for specific inboxes. Additionally,
it also adds a new resolution count heatmap that shows when support
teams are most active in resolving conversations, using a green color to
distinguish it from the blue conversation heatmap.

The PR also reorganizes heatmap components into a cleaner structure with
a shared `BaseHeatmapContainer` that handles common functionality like
date range selection, inbox filtering, and data fetching. This makes it
easy to add new heatmap metrics in the future - just create a wrapper
component specifying the metric type and color scheme.

<img width="1926" height="1670" alt="CleanShot 2025-10-13 at 14 01
35@2x"
src="https://github.com/user-attachments/assets/67822a34-6170-4d19-9e11-7ad4ded5c388"
/>

<img width="1964" height="1634" alt="CleanShot 2025-10-13 at 14 03
00@2x"
src="https://github.com/user-attachments/assets/e4613c08-64b8-4fa6-91d8-7510946dd75d"
/>


Unrelated change, the data seeder conversation resolution would not work
correctly, we've fixed it.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 19:15:57 +05:30
38f16ba677 feat: Secure external credentials with database encryption (#12648)
## Changelog

- Added conditional Active Record encryption to every external
credential we store (SMTP/IMAP passwords, Twilio tokens,
Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys,
Twitter secrets) so new writes are encrypted
whenever Chatwoot.encryption_configured? is true; legacy installs still
receive plaintext until their secrets are
    updated.
- Tuned encryption settings in config/application.rb to allow legacy
reads (support_unencrypted_data) and to extend
deterministic queries so lookups continue to match plaintext rows during
the rollout; added TODOs to retire the
    fallback once encryption becomes mandatory.
- Introduced an MFA-pipeline test suite
(spec/models/external_credentials_encryption_spec.rb) plus shared
examples to
verify each attribute encrypts at rest and that plaintext records
re-encrypt on update, with a dedicated Telegram case.
The existing MFA GitHub workflow now runs these tests using the
preconfigured encryption keys.

fixes:
https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database

## Testing Instructions

 1. Instance without encryption keys
- Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where
they’re absent).
      - Create at least one credentialed channel (e.g., Email SMTP).
- Confirm workflows still function (send/receive mail or a similar
sanity check).
- In the DB you should still see plaintext values—this confirms the
guard prevents encryption when keys are missing.
  2. Instance with encryption keys
      - Configure the three encryption env vars and restart.
- Pick a couple of representative integrations (e.g., Email SMTP +
Twilio SMS).
      - Legacy channel check:
- Use existing records created before enabling keys. Trigger their
workflow (send an email / SMS, or hit the
            webhook) to ensure they still authenticate.
- Inspect the raw column—value remains plaintext until changed.
      - Update legacy channel:
- Edit one legacy channel’s credential (e.g., change SMTP password).
- Verify the operation still works and the stored value is now encrypted
(raw column differs, accessor returns
            original).
      - New channel creation:
- Create a new channel of the same type; confirm functionality and that
the stored credential is encrypted from
            the start.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 18:05:12 +05:30
e7b01d80b3 chore: add script to throttle bulkreindex job creation and increase meta timeouts(#12626)
- scripts to throttle reindex job creation and monitor progress

```

RAILS_ENV=production POSTGRES_STATEMENT_TIMEOUT=6000s bundle exec rails runner script/bulk_reindex_messages.rb

RAILS_ENV=production bundle exec rails runner script/monitor_reindex.rb
```

---------
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-10-13 16:21:45 +05:30
ec9a82a017 feat: Open conversation when agent bot webhook fails (#12379)
# Changelog

When an agent bot webhook fails, we now flip any pending conversation
back to an open state so a human agent can pick
it up immediately. There will be an clear activity message giving the
team clear visibility into what went
wrong. This keeps customers from getting stuck in limbo when their
connected bot goes offline.

# Testing instructions 

1. Initial setup: Create an agent bot with a working webhook URL and
connect it to a test inbox. Send a message from a
contact (e.g., via the widget) so a conversation is created; it should
enter the Pending state while the bot handles
     the reply.
2. Introduce failure: Edit that agent bot and swap the webhook URL for a
dummy endpoint that will fail. Have the same
contact send another message in the existing conversation. Because the
webhook call now fails, the conversation should flip from Pending back
to Open, making it visible to agents. Also verify the activity message
3. New conversation check: With the dummy URL still in place, start a
brand-new conversation from a contact. When the
bot tries (and fails) to respond, confirm that the conversation appears
immediately as Open rather than remaining Pending. Also the activity
message is visible
4. Subsequent messages in open conversations will show no change

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-13 15:59:59 +05:30
Tanmay Sharma 62203da528 fix up extra env 2025-10-13 13:29:00 +05:30
Tanmay Sharma 029294e16b Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2 2025-10-12 17:26:53 +05:30
Tanmay Sharma 2c095bed86 fix spec rubcop 2025-10-12 17:26:07 +05:30
Tanmay Deep SharmaandGitHub 7c6508dd04 Merge branch 'develop' into feature/stripe_v2 2025-10-12 15:47:27 +04:00
Tanmay Sharma cef23891af cleanup code, remove unnecessary 2025-10-12 17:02:16 +05:30
Tanmay Sharma 19a851c01d cleanup code, remove unnecessary 2025-10-11 11:56:16 +04:00
Tanmay Sharma a22e11c810 cleanup code 2025-10-11 10:25:58 +04:00
Tanmay Sharma efe6e64dcd cleanup code 2025-10-11 10:22:46 +04:00
Tanmay Deep Sharma d6f5c627eb fix rspec and customer subscription process 2025-10-10 13:03:27 +02:00
Tanmay Sharma 51b4a8f9fc cleanup 2025-10-09 17:38:27 +02:00
Tanmay Sharma 1268b2cf55 fix schema file 2025-10-09 16:24:32 +02:00
Tanmay Sharma 125ae5523a fix stripe v2 calls 2025-10-09 09:52:56 +02:00
Tanmay Sharma 59f60936a7 fix migration files 2025-10-09 08:32:13 +02:00
Tanmay Deep Sharma 216a2c0e73 do not disable rubocop 2025-10-09 01:04:47 +02:00
Tanmay Deep Sharma 708296a746 code clean up 2025-10-09 00:37:55 +02:00
Tanmay Deep Sharma b9168664f7 stripe v2 fix meter usage 2025-10-08 23:29:31 +02:00
Tanmay Deep Sharma 01cd7b878d stripe v2 init 2025-10-08 10:38:38 +02:00
Tanmay Deep Sharma acb2816826 stripe v2 init 2025-10-08 10:11:23 +02:00
89 changed files with 5513 additions and 531 deletions
+1
View File
@@ -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:
+5
View File
@@ -99,3 +99,8 @@ CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/
# External documentation
\[External\]*
+1 -1
View File
@@ -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'],
},
},
],
},
],
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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 };
}
+2
View File
@@ -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',
+4
View File
@@ -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
+1
View File
@@ -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
+6
View File
@@ -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,
+6
View File
@@ -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 }
+3
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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 }
+28
View File
@@ -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
+3
View File
@@ -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' }
+28
View File
@@ -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
+6
View File
@@ -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? &&
+2
View File
@@ -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}'
+7
View File
@@ -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
+8
View File
@@ -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"
+1
View File
@@ -27,6 +27,7 @@
- purgable
- housekeeping
- async_database_migration
- bulk_reindex_low
- active_storage_analysis
- active_storage_purge
- action_mailbox_incineration
+142
View File
@@ -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
View File
@@ -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
+111
View File
@@ -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
+25 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+58
View File
@@ -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
+19
View File
@@ -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
+58
View File
@@ -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
@@ -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
@@ -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
+64 -1
View File
@@ -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