Compare commits

..
Author SHA1 Message Date
Tanmay Sharma 79890710bd Merge remote-tracking branch 'origin/stripe_v2/cancel_subscription' into stripe_v2/topup 2025-11-24 19:44:48 +05:30
Tanmay Sharma f481627032 Merge remote-tracking branch 'origin/stripe_v2/update_subscription' into stripe_v2/cancel_subscription 2025-11-24 19:40:58 +05:30
Tanmay Sharma afa7f2d325 add default payment method for change of subscription 2025-11-24 19:38:38 +05:30
Tanmay Sharma 91bfc47f01 add change pricing plan view 2025-11-24 19:18:02 +05:30
Tanmay Sharma 862e33e7b9 Merge remote-tracking branch 'origin/stripe_v2/base' into stripe_v2/update_subscription 2025-11-24 19:16:49 +05:30
Tanmay Sharma 7d2307d343 fix spec handle stripe event service spec 2025-11-24 19:15:01 +05:30
Tanmay Deep Sharma 69b818896a update plan catalog display_name 2025-11-19 20:01:49 +05:30
Tanmay Deep Sharma 01d17674e6 fix comments 2025-11-19 19:39:40 +05:30
Tanmay Deep Sharma 689525ce85 fix credit addition for topup and monthly 2025-11-17 11:57:27 +05:30
Tanmay Sharma 6abecdfd43 update the logic to get the number of credits for a monthly plan 2025-11-17 11:15:21 +05:30
Tanmay Deep SharmaandGitHub 7ac8e857c7 Merge branch 'stripe_v2/base' into stripe_v2/update_subscription 2025-11-17 10:23:15 +05:30
Tanmay Deep Sharma 418d9cce27 update stripe controller rspec 2025-11-10 20:44:10 +05:30
Tanmay Deep Sharma eba12567e9 cursor review commit fixes 2025-11-10 19:15:20 +05:30
Tanmay Deep Sharma d230bb68d0 add updated rspecs 2025-11-10 19:06:24 +05:30
Tanmay Deep SharmaandGitHub d3bbfd5c55 Merge branch 'stripe_v2/update_subscription' into stripe_v2/cancel_subscription 2025-11-10 18:01:15 +05:30
Tanmay Deep Sharma 7babcfe622 add updated rspecs 2025-11-10 17:55:57 +05:30
Tanmay Deep Sharma f31666f877 feat: add update subscription in controller 2025-11-10 17:42:09 +05:30
Tanmay Deep Sharma 3055a7c6f0 feat: add cancel subscription in controller 2025-11-10 17:41:17 +05:30
Tanmay Deep Sharma 1e11f271f0 feat: add support for topup credits 2025-11-10 17:40:17 +05:30
Tanmay Deep Sharma 8d702ad299 feat: add support for subscription cancellation 2025-11-10 17:18:50 +05:30
Tanmay Deep Sharma 4c1a047c94 feat: add changes to support update subscription 2025-11-10 17:14:59 +05:30
Tanmay Deep Sharma c853d07f02 init: base for stripe V2 2025-11-10 14:28:18 +05:30
112 changed files with 2924 additions and 4213 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
gem 'stripe', '17.2.0.pre.alpha.2'
## - helper gems --##
## to populate db with sample data
+2 -2
View File
@@ -902,7 +902,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
stripe (17.2.0.pre.alpha.2)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1110,7 +1110,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe
stripe (= 17.2.0.pre.alpha.2)
telephone_number
test-prof
tidewave
+43 -32
View File
@@ -1,8 +1,5 @@
class Messages::MessageBuilder
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
@@ -41,12 +38,30 @@ class Messages::MessageBuilder
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments
return if @attachments.blank?
@@ -95,6 +110,12 @@ class Messages::MessageBuilder
email_string.gsub(/\s+/, '').split(',')
end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -157,17 +178,14 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(processed_content)
build_html_content(normalized_content)
end
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
@@ -186,6 +204,22 @@ class Messages::MessageBuilder
text_content
end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
@@ -198,27 +232,4 @@ class Messages::MessageBuilder
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
@@ -22,10 +22,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end
def create
params_with_defaults = article_params
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end
def fetch_webhook
+4 -1
View File
@@ -8,7 +8,10 @@ module BillingHelper
# Return false if not plans are configured, so that no checks are enforced
return false if default_plan.blank?
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
# Handle both string and hash formats for default_plan
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
end
def conversations_this_month(account)
-24
View File
@@ -1,24 +0,0 @@
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
# This module contains helper methods for converting between different data types,
# normalizing hashes, and safely handling JSON operations.
module DataHelper
# Ensures a hash supports indifferent access (string or symbol keys).
# Returns an empty hash if the input is blank.
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
def safe_parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
end
+1 -21
View File
@@ -4,19 +4,6 @@ module EmailHelper
domain.split('.').first
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
def validate_email_addresses(emails_to_test)
emails_to_test&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
# This is not a mandatory requirement for email addresses, but it is a common practice.
# john+test@xyc.com is the same as john@xyc.com
@@ -34,10 +21,6 @@ module EmailHelper
end
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
@@ -46,10 +29,7 @@ module EmailHelper
def message_drops(conversation)
{
'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(conversation.inbox),
'account' => AccountDrop.new(conversation.account)
'contact' => ContactDrop.new(conversation.contact)
}
end
end
@@ -4,6 +4,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
@@ -116,57 +117,58 @@ const handleCreateAssistant = () => {
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<div class="flex gap-3 items-center">
<div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :back-url="backUrl" />
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
<slot name="headerTitle">
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
<Icon
v-if="activeAssistantName"
icon="i-lucide-chevron-right"
class="size-6 text-n-slate-11"
/>
<span class="text-xl font-medium text-n-slate-11">
{{ headerTitle }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
>
<span v-else class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</slot>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
@@ -75,9 +75,9 @@ const sendMessage = async () => {
<template>
<div
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
>
<div class="mb-8 px-6">
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
@@ -85,7 +85,6 @@ const sendMessage = async () => {
<NextButton
ghost
sm
slate
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
@@ -98,11 +97,11 @@ const sendMessage = async () => {
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
const scrollToBottom = async () => {
await nextTick();
@@ -49,10 +49,7 @@ watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
v-for="(message, index) in messages"
:key="index"
@@ -60,20 +57,15 @@ watch(() => props.messages.length, scrollToBottom);
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
class="flex items-start gap-1.5"
:class="getMessageDirection(message.sender)"
>
<Avatar
:name="getAvatarName(message.sender)"
rounded-full
:size="24"
class="shrink-0"
/>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<div
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)"
>
<div v-html="formatMessage(message.content)" />
<div class="break-words" v-html="formatMessage(message.content)" />
</div>
</div>
</div>
@@ -8,15 +8,11 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
menuItems: {
type: Array,
default: () => [],
required: true,
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: {
type: Number,
default: 20,
@@ -46,62 +42,19 @@ const { t } = useI18n();
const searchInput = ref(null);
const searchQuery = ref('');
const hasSections = computed(() => props.menuSections.length > 0);
const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value;
if (!searchQuery.value) return props.menuItems;
return flattenedMenuItems.value.filter(item =>
return props.menuItems.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
};
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
@@ -111,122 +64,54 @@ onMounted(() => {
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div v-if="showSearch" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
{{ item.label }}
</span>
</button>
<div
v-if="shouldShowEmptyState"
v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
@@ -1,9 +1,10 @@
<script setup>
import { defineProps, computed, ref } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* Props definition for the component
@@ -39,15 +40,53 @@ const props = defineProps({
});
const emit = defineEmits(['retry']);
const store = useStore();
const fetchingConversations = ref(new Set());
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -87,43 +126,36 @@ const shouldGroupWithNext = (index, searchList) => {
* @returns {Object|null} - The message being replied to, or null if not found
*/
const getInReplyToMessage = parentMessage => {
if (!parentMessage) return null;
const inReplyToMessageId =
parentMessage?.contentAttributes?.inReplyTo ??
parentMessage?.content_attributes?.in_reply_to;
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
if (!inReplyToMessageId) return null;
// 1. Check props messages (already camelCased via allMessages)
const foundInProps = allMessages.value?.find(
m => m.id === inReplyToMessageId
);
if (foundInProps) return foundInProps;
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// 2. Check store messages
const foundInStore = currentChat.value?.messages?.find(
m => m.id === inReplyToMessageId
);
if (foundInStore) return useCamelCase(foundInStore);
// 3. Fetch if not currently fetching for this conversation
const conversationId = currentChat.value?.id;
if (
conversationId &&
!currentChat.value.allMessagesLoaded &&
!fetchingConversations.value.has(conversationId)
) {
fetchingConversations.value.add(conversationId);
store
.dispatch('fetchPreviousMessages', {
conversationId,
before: currentChat.value.messages?.[0]?.id ?? null,
})
.finally(() => {
fetchingConversations.value.delete(conversationId);
});
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
return null;
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
};
</script>
@@ -46,10 +46,6 @@
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
}
},
"NAME": {
"LABEL": "Webhook Name",
"PLACEHOLDER": "Enter the name of the webhook"
},
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: {webhookExampleURL}",
@@ -53,8 +53,6 @@
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
@@ -10,14 +10,14 @@ const assistantId = computed(() => Number(route.params.assistantId));
<template>
<PageLayout
:header-title="$t('CAPTAIN.PLAYGROUND.HEADER')"
show-assistant-switcher
:show-pagination-footer="false"
:show-know-more="false"
class="h-full"
>
<template #body>
<div class="flex flex-col h-full">
<AssistantPlayground :assistant-id="assistantId" class="bg-n-solid-1" />
<AssistantPlayground :assistant-id="assistantId" />
</div>
</template>
</PageLayout>
@@ -104,6 +104,7 @@ const handleDeleteSuccess = () => {
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.SETTINGS.HEADER')"
:is-fetching="isFetching"
:show-pagination-footer="false"
:show-know-more="false"
@@ -195,10 +195,7 @@ const navigateToPendingFAQs = () => {
onMounted(() => {
initializeFromURL();
store.dispatch(
'captainResponses/fetchPendingCount',
selectedAssistantId.value
);
store.dispatch('captainResponses/fetchPendingCount', selectedAssistantId);
});
</script>
@@ -55,7 +55,6 @@ export default {
data() {
return {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
@@ -69,15 +68,11 @@ export default {
}
);
},
webhookNameInputPlaceholder() {
return this.$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.PLACEHOLDER');
},
},
methods: {
onSubmit() {
this.$emit('submit', {
url: this.url,
name: this.name,
subscriptions: this.subscriptions,
});
},
@@ -102,15 +97,6 @@ export default {
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
name="name"
:placeholder="webhookNameInputPlaceholder"
/>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>
@@ -37,16 +37,8 @@ const subscribedEvents = computed(() => {
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex gap-2 font-medium break-words text-n-slate-12">
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-n-slate-11">
{{ webhook.url }}
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
<div class="font-medium break-words text-n-slate-12">
{{ webhook.url }}
</div>
<div class="block mt-1 text-sm text-n-slate-11">
<span class="font-medium">
@@ -119,7 +119,7 @@ const tooltip = useHeatmapTooltip();
<!-- 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]"
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">
@@ -1,18 +1,15 @@
<script setup>
import { onMounted, ref, computed, watch } from 'vue';
import { onMounted, ref, computed } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from '../overview/MetricCard.vue';
import BaseHeatmap from './BaseHeatmap.vue';
import HeatmapDateRangeSelector from './HeatmapDateRangeSelector.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay';
import format from 'date-fns/format';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
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';
@@ -60,33 +57,27 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
const heatmapData = useMapGetter(props.storeGetter);
const inboxes = useMapGetter('inboxes/getInboxes');
const selectedFrom = ref(null);
const selectedTo = ref(null);
const selectedDaysBefore = ref(null);
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 isMonthFilter = ref(false);
const currentMonthOffset = ref(0);
const selectedRange = computed(() => {
if (!selectedFrom.value || !selectedTo.value) {
return null;
}
return {
from: selectedFrom.value,
to: selectedTo.value,
};
});
const numberOfRows = computed(() => {
if (!selectedRange.value) {
return 0;
}
const dateDifference = differenceInCalendarDays(
selectedRange.value.to,
selectedRange.value.from
);
return dateDifference + 1;
});
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const inboxMenuItems = computed(() => {
return [
@@ -114,42 +105,13 @@ const selectedInboxFilter = computed(() => {
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
// Keeps relative presets (last 7 days / this month) aligned with "now" during live refreshes.
const resolveActiveRange = () => {
if (isMonthFilter.value && currentMonthOffset.value === 0) {
const now = new Date();
const monthStart = startOfMonth(now);
return {
from: startOfDay(monthStart),
to: endOfDay(now),
};
}
if (!isMonthFilter.value && selectedDaysBefore.value !== null) {
const to = endOfDay(new Date());
return {
from: startOfDay(subDays(to, Number(selectedDaysBefore.value))),
to,
};
}
return selectedRange.value;
};
const downloadHeatmapData = () => {
const range = resolveActiveRange();
if (!range) {
return;
}
const { to } = range;
const shouldUseBackendDownload =
!isMonthFilter.value && !selectedInbox.value && props.downloadAction;
const to = endOfDay(new Date());
// If no inbox is selected and download action exists, use backend endpoint
if (shouldUseBackendDownload) {
if (!selectedInbox.value && props.downloadAction) {
store.dispatch(props.downloadAction, {
daysBefore: selectedDaysBefore.value,
daysBefore: selectedDays.value,
to: getUnixTime(to),
});
return;
@@ -188,6 +150,7 @@ const downloadHeatmapData = () => {
downloadCsvFile(fileName, csvContent);
};
const [showDropdown, toggleDropdown] = useToggle();
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
const fetchHeatmapData = () => {
@@ -195,12 +158,8 @@ const fetchHeatmapData = () => {
return;
}
const range = resolveActiveRange();
if (!range) {
return;
}
const { from, to } = range;
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
const params = {
metric: props.metric,
@@ -219,43 +178,25 @@ const fetchHeatmapData = () => {
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);
const handleRangeTypeChange = type => {
isMonthFilter.value = type === 'month';
};
const handleMonthOffsetChange = offset => {
currentMonthOffset.value = offset;
};
watch(
() => [selectedFrom.value, selectedTo.value],
([from, to]) => {
if (from && to) {
fetchHeatmapData();
}
}
);
watch(
() => selectedInbox.value,
() => {
if (selectedRange.value) {
fetchHeatmapData();
}
}
);
onMounted(() => {
store.dispatch('inboxes/get');
fetchHeatmapData();
startRefetching();
});
</script>
@@ -264,13 +205,25 @@ onMounted(() => {
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="title">
<template #control>
<HeatmapDateRangeSelector
v-model:from="selectedFrom"
v-model:to="selectedTo"
v-model:days-num="selectedDaysBefore"
@range-type-change="handleRangeTypeChange"
@month-offset-change="handleMonthOffsetChange"
/>
<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"
@@ -288,23 +241,22 @@ onMounted(() => {
: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-56 max-w-56 max-h-96 overflow-y-auto"
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
v-tooltip="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
sm
slate
faded
icon="i-lucide-download"
: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="numberOfRows"
:number-of-rows="selectedDays + 1"
:is-loading="isLoading"
:color-scheme="colorScheme"
/>
@@ -1,211 +0,0 @@
<script setup>
import { computed, ref, watch, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import addMonths from 'date-fns/addMonths';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay';
import endOfMonth from 'date-fns/endOfMonth';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import subDays from 'date-fns/subDays';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['rangeTypeChange', 'monthOffsetChange']);
const fromModel = defineModel('from', { type: Date, default: null });
const toModel = defineModel('to', { type: Date, default: null });
const daysNumModel = defineModel('daysNum', { type: Number, default: null });
const { t, locale } = useI18n();
const DATE_FILTER_TYPES = {
DAY: 'day',
MONTH: 'month',
};
const DATE_FILTER_ACTION = 'select_date_range';
const dayMenuItemConfigs = computed(() => [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 'last_7_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 'last_14_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 'last_30_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 29,
},
]);
const resolvedLocale = computed(
() =>
locale.value ||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
);
const monthFormatter = computed(
() =>
new Intl.DateTimeFormat(resolvedLocale.value, {
month: 'long',
year: 'numeric',
})
);
const monthMenuItemConfigs = computed(() => {
const now = new Date();
const offsets = [0, -1, -2];
return offsets.map(offset => ({
label:
offset === 0
? t('REPORT.DATE_RANGE_OPTIONS.THIS_MONTH')
: monthFormatter.value.format(addMonths(now, offset)),
value: offset === 0 ? 'this_month' : `month_${offset}`,
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.MONTH,
monthOffset: offset,
}));
});
const selectedDateRangeValue = ref('');
const [showDropdown, toggleDropdown] = useToggle();
const monthOffset = ref(0);
const menuItems = computed(() => {
const selectedValue = selectedDateRangeValue.value;
return [...dayMenuItemConfigs.value, ...monthMenuItemConfigs.value].map(
config => ({
...config,
isSelected: selectedValue === config.value,
})
);
});
selectedDateRangeValue.value = menuItems.value[0]?.value || '';
const menuSections = computed(() => {
const dayItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.DAY
);
const monthItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.MONTH
);
return [{ items: dayItems }, { items: monthItems }].filter(
section => section.items.length > 0
);
});
const selectedConfig = computed(
() =>
menuItems.value.find(
menuItem => menuItem.value === selectedDateRangeValue.value
) || menuItems.value[0]
);
const selectedLabel = computed(() => {
const selectedItem = menuItems.value.find(
item => item.value === selectedDateRangeValue.value
);
return selectedItem?.label || '';
});
const computeRange = config => {
if (!config) {
return null;
}
if (config.type === DATE_FILTER_TYPES.MONTH) {
const now = new Date();
const baseMonthStart = startOfMonth(addMonths(now, monthOffset.value));
const from = startOfDay(baseMonthStart);
const isCurrentMonth =
config.value === 'this_month' && monthOffset.value === 0;
const to = isCurrentMonth
? endOfDay(now)
: endOfDay(endOfMonth(baseMonthStart));
const daysBefore = differenceInCalendarDays(to, from);
return { from, to, daysBefore };
}
const to = endOfDay(new Date());
const from = startOfDay(subDays(to, Number(config.daysBefore)));
return { from, to, daysBefore: Number(config.daysBefore) };
};
const applySelection = config => {
if (!config) return;
if (config.type === DATE_FILTER_TYPES.MONTH) {
monthOffset.value = config.monthOffset || 0;
} else {
monthOffset.value = 0;
}
const range = computeRange(config);
if (!range) return;
const { from, to, daysBefore } = range;
fromModel.value = from;
toModel.value = to;
daysNumModel.value = daysBefore;
emit('rangeTypeChange', config.type);
emit('monthOffsetChange', monthOffset.value);
};
const handleAction = ({ action, value }) => {
toggleDropdown(false);
if (action !== DATE_FILTER_ACTION) {
return;
}
selectedDateRangeValue.value = value;
};
watch(
() => selectedConfig.value,
config => {
applySelection(config);
},
{ immediate: true }
);
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -74,17 +74,9 @@ export const mutations = {
[types.SET_PREVIOUS_CONVERSATIONS](_state, { id, data }) {
if (data.length) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (chat) {
// Duplicate check: only add messages that don't already exist
const existingIds = new Set(chat.messages.map(m => m.id));
const newMessages = data.filter(m => !existingIds.has(m.id));
if (newMessages.length) {
chat.messages.unshift(...newMessages);
}
}
chat.messages.unshift(...data);
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
_state.attachments[id] = [...data];
},
@@ -615,29 +615,6 @@ describe('#mutations', () => {
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg2' }]);
});
it('should filter out duplicate messages', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [{ id: 'msg1' }, { id: 'msg2' }] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([
{ id: 'msg1' },
{ id: 'msg2' },
]);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [{ id: 2, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [{ id: 'msg1' }] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg2' }]);
});
});
describe('#SET_MISSING_MESSAGES', () => {
+1 -18
View File
@@ -8,7 +8,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
def perform(entries)
@entries = entries
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
with_lock(key) do
process_entries(entries)
end
@@ -77,23 +77,6 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.first&.dig(:id)
end
def contact_instagram_id
entry = @entries&.first
return nil unless entry
# Handle both messaging and standby arrays
messaging = (entry[:messaging].presence || entry[:standby] || []).first
return nil unless messaging
# For echo messages (outgoing from our account), use recipient's ID (the contact)
# For incoming messages (from contact), use sender's ID (the contact)
if messaging.dig(:message, :is_echo)
messaging.dig(:recipient, :id)
else
messaging.dig(:sender, :id)
end
end
def sender_id
@entries&.dig(0, :messaging, 0, :sender, :id)
end
+24 -7
View File
@@ -4,21 +4,38 @@ class ApplicationMailbox < ActionMailbox::Base
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
# Route all emails to verified channels to the unified reply mailbox
# The ConversationFinder will determine if it's a reply or new conversation
# routes as a reply to existing conversations
routing(
lambda { |inbound_mail|
valid_to_address?(inbound_mail) &&
(reply_uuid_mail?(inbound_mail) || EmailChannelFinder.new(inbound_mail.mail).perform.present?)
} => :reply
->(inbound_mail) { valid_to_address?(inbound_mail) && (reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail)) } => :reply
)
# routes as a new conversation in email channel
routing(
->(inbound_mail) { valid_to_address?(inbound_mail) && EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
)
# catchall
routing(all: :default)
class << self
# checks if follows this pattern: reply+<conversation-uuid>@<mailer-domain.com>
# checks if follow this pattern then send it to reply_mailbox
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
def in_reply_to_mail?(inbound_mail)
in_reply_to = inbound_mail.mail.in_reply_to
in_reply_to.present? && (
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
)
end
def in_reply_to_matches?(in_reply_to)
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
end
# checks if follow this pattern send it to reply_mailbox
# reply+<conversation-uuid>@<mailer-domain.com>
def reply_uuid_mail?(inbound_mail)
inbound_mail.mail.to&.any? do |email|
conversation_uuid = email.split('@')[0]
+4 -19
View File
@@ -3,8 +3,6 @@ class Imap::ImapMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
FALLBACK_CONVERSATION_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def process(mail, channel)
@inbound_mail = mail
@channel = channel
@@ -51,32 +49,19 @@ class Imap::ImapMailbox
end
def find_conversation_by_reference_ids
return if @inbound_mail.references.blank?
return if @inbound_mail.references.blank? && in_reply_to.present?
message = find_message_by_references
if message.present?
conversation = @inbox.conversations.find_by(id: message.conversation_id)
return conversation if conversation.present?
end
# FALLBACK_PATTERN use to find a conversation that is started by an agent (no incoming message yet)
conversation_id = find_conversation_by_references
@inbox.conversations.find_by(uuid: conversation_id) if conversation_id.present?
return if message.nil?
@inbox.conversations.find(message.conversation_id)
end
def in_reply_to
@processed_mail.in_reply_to
end
def find_conversation_by_references
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
return match[2] if match.present?
end
end
def find_message_by_references
message_to_return = nil
+73 -23
View File
@@ -1,38 +1,88 @@
class ReplyMailbox < ApplicationMailbox
attr_accessor :conversation, :processed_mail
attr_accessor :conversation_uuid, :processed_mail
before_processing :find_conversation
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
before_processing :conversation_uuid_from_to_address,
:find_relative_conversation
def process
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
return if @conversation.blank?
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
# and ensures idempotency on job retry (conversation won't be duplicated)
ActiveRecord::Base.transaction do
persist_conversation_if_needed
decorate_mail
create_message
add_attachments_to_message
end
decorate_mail
create_message
add_attachments_to_message
end
private
def find_conversation
@conversation = Mailbox::ConversationFinder.new(mail).find
# Log when email is rejected
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
def find_relative_conversation
if @conversation_uuid
find_conversation_with_uuid
elsif mail.in_reply_to.present?
find_conversation_with_in_reply_to
end
end
def persist_conversation_if_needed
# Save the conversation if it's a new record (from NewConversationStrategy)
# We persist here instead of in the strategy to maintain transaction integrity
return unless @conversation.new_record?
def conversation_uuid_from_to_address
@mail = MailPresenter.new(mail)
@conversation.save!
Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
return if @mail.mail_receiver.blank?
@mail.mail_receiver.each do |email|
username = email.split('@')[0]
match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN)
if match_result
@conversation_uuid = match_result.captures
break
end
end
@conversation_uuid
end
# find conversation uuid from below pattern
# reply+<conversation-uuid>@<mailer-domain.com>
def find_conversation_with_uuid
@conversation = Conversation.find_by(uuid: conversation_uuid)
validate_resource @conversation
end
def find_conversation_by_uuid(match_result)
@conversation_uuid = match_result.captures[0]
find_conversation_with_uuid
end
def find_conversation_by_message_id(in_reply_to)
@message = Message.find_by(source_id: in_reply_to)
@conversation = @message.conversation if @message.present?
@conversation_uuid = @conversation.uuid if @conversation.present?
end
# find conversation uuid from below pattern
# <conversation/#{@conversation.uuid}/messages/#{@messages&.last&.id}@#{@account.inbound_email_domain}>
def find_conversation_with_in_reply_to
match_result = nil
in_reply_to_addresses = mail.in_reply_to
in_reply_to_addresses = [in_reply_to_addresses] if in_reply_to_addresses.is_a?(String)
in_reply_to_addresses.each do |in_reply_to|
match_result = in_reply_to.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
break if match_result
end
find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
end
def find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
find_conversation_by_uuid(match_result) if match_result
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
end
def validate_resource(resource)
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
resource
end
def decorate_mail
+94
View File
@@ -0,0 +1,94 @@
class SupportMailbox < ApplicationMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
before_processing :find_channel,
:load_account,
:load_inbox,
:decorate_mail
def process
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
# Skip processing email if it belongs to any of the edge cases
return unless incoming_email_from_valid_email?
ActiveRecord::Base.transaction do
find_or_create_contact
find_or_create_conversation
create_message
add_attachments_to_message
end
end
private
def find_channel
find_channel_with_to_mail if @channel.blank?
raise 'Email channel/inbox not found' if @channel.nil?
@channel
end
def find_channel_with_to_mail
@channel = EmailChannelFinder.new(mail).perform
end
def load_account
@account = @channel.account
end
def load_inbox
@inbox = @channel.inbox
end
def decorate_mail
@processed_mail = MailPresenter.new(mail, @account)
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
end
def in_reply_to
mail['In-Reply-To'].try(:value)
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
})
end
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
create_contact
end
end
def identify_contact_name
processed_mail.sender_name || processed_mail.from.first.split('@').first
end
end
-17
View File
@@ -35,21 +35,4 @@ class ReportingEvent < ApplicationRecord
belongs_to :user, optional: true
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
# Scopes for filtering
scope :filter_by_date_range, lambda { |range|
where(created_at: range) if range.present?
}
scope :filter_by_inbox_id, lambda { |inbox_id|
where(inbox_id: inbox_id) if inbox_id.present?
}
scope :filter_by_user_id, lambda { |user_id|
where(user_id: user_id) if user_id.present?
}
scope :filter_by_name, lambda { |name|
where(name: name) if name.present?
}
end
-1
View File
@@ -3,7 +3,6 @@
# Table name: webhooks
#
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :string
# webhook_type :integer default("account_type")
+28
View File
@@ -30,4 +30,32 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@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 credit_grants?
@account_user.administrator?
end
def change_pricing_plan?
@account_user.administrator?
end
end
@@ -1,29 +0,0 @@
class Mailbox::ConversationFinder
DEFAULT_STRATEGIES = [
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
Mailbox::ConversationFinderStrategies::ReferencesStrategy,
Mailbox::ConversationFinderStrategies::NewConversationStrategy
].freeze
def initialize(mail, strategies: DEFAULT_STRATEGIES)
@mail = mail
@strategies = strategies
end
def find
@strategies.each do |strategy_class|
conversation = strategy_class.new(@mail).find
next unless conversation
strategy_name = strategy_class.name.demodulize.underscore
Rails.logger.info "Conversation found via #{strategy_name} strategy"
return conversation
end
# Should not reach here if NewConversationStrategy is in the chain
Rails.logger.error 'No conversation found via any strategy (NewConversationStrategy missing?)'
nil
end
end
@@ -1,13 +0,0 @@
class Mailbox::ConversationFinderStrategies::BaseStrategy
attr_reader :mail
def initialize(mail)
@mail = mail
end
# Returns Conversation or nil
# Subclasses must implement this method
def find
raise NotImplementedError, "#{self.class} must implement #find"
end
end
@@ -1,48 +0,0 @@
class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building In-Reply-To headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def find
return nil if mail.in_reply_to.blank?
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
in_reply_to_addresses.each do |in_reply_to|
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(in_reply_to)
if uuid
conversation = Conversation.find_by(uuid: uuid)
return conversation if conversation
end
# Try finding by message source_id
message = Message.find_by(source_id: in_reply_to)
return message.conversation if message&.conversation
end
nil
end
private
def extract_uuid_from_patterns(message_id)
# Try message-specific pattern first
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
# Try conversation fallback pattern
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end
@@ -1,83 +0,0 @@
class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
include MailboxHelper
include IncomingEmailValidityHelper
attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation, :channel
def initialize(mail)
super(mail)
@channel = EmailChannelFinder.new(mail).perform
return unless @channel
@account = @channel.account
@inbox = @channel.inbox
@processed_mail = MailPresenter.new(mail, @account)
end
# This strategy prepares a new conversation but doesn't persist it yet.
# Why we don't use create! here:
# - Avoids orphan conversations if message/attachment creation fails later
# - Prevents duplicate conversations on job retry (no idempotency issue)
# - Follows the pattern from old SupportMailbox where everything was in one transaction
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
existing_conversation = find_conversation_by_in_reply_to
return existing_conversation if existing_conversation
# Prepare contact (persisted) and build conversation (not persisted)
find_or_create_contact
build_conversation
end
private
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
create_contact
end
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def identify_contact_name
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
end
def build_conversation
# Build but don't persist - ReplyMailbox will save in transaction with message
@conversation = ::Conversation.new(
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
)
end
def in_reply_to
mail['In-Reply-To'].try(:value)
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
end
end
@@ -1,26 +0,0 @@
class Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Pattern from ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN
UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
def find
uuid = extract_uuid_from_receivers
return nil unless uuid
Conversation.find_by(uuid: uuid)
end
private
def extract_uuid_from_receivers
mail_presenter = MailPresenter.new(mail)
return nil if mail_presenter.mail_receiver.blank?
mail_presenter.mail_receiver.each do |email|
username = email.split('@').first
match = username.match(UUID_PATTERN)
return match[1] if match
end
nil
end
end
@@ -1,59 +0,0 @@
class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building References headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def initialize(mail)
super(mail)
# Get channel once upfront to use for scoped queries
@channel = EmailChannelFinder.new(mail).perform
end
def find
return nil if mail.references.blank?
return nil unless @channel # No valid channel found
references = Array.wrap(mail.references)
references.each do |reference|
conversation = find_conversation_from_reference(reference)
return conversation if conversation
end
nil
end
private
def find_conversation_from_reference(reference)
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(reference)
if uuid
# Query scoped to inbox - prevents cross-account/cross-inbox matches at database level
conversation = Conversation.find_by(uuid: uuid, inbox_id: @channel.inbox.id)
return conversation if conversation
end
# We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
message&.conversation
end
def extract_uuid_from_patterns(message_id)
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end
@@ -1,5 +1,4 @@
json.id webhook.id
json.name webhook.name
json.url webhook.url
json.account_id webhook.account_id
json.subscriptions webhook.subscriptions
@@ -6,6 +6,17 @@ if resource.custom_attributes.present?
json.subscribed_quantity resource.custom_attributes['subscribed_quantity']
json.subscription_status resource.custom_attributes['subscription_status']
json.subscription_ends_on resource.custom_attributes['subscription_ends_on']
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present?
json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present?
if resource.custom_attributes['pending_stripe_pricing_plan_id'].present?
json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id']
end
if resource.custom_attributes['pending_subscription_quantity'].present?
json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity']
end
json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present?
json.next_billing_date resource.custom_attributes['next_billing_date'] if resource.custom_attributes['next_billing_date'].present?
json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present?
json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present?
json.timezone resource.custom_attributes['timezone'] if resource.custom_attributes['timezone'].present?
+11
View File
@@ -119,6 +119,12 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
enterprise:
billing:
topup_amount_invalid: Topup amount must be greater than 0
stripe_customer_required: Customer ID required. Please create a Stripe customer first.
lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id}
v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.
profile:
mfa:
enabled: MFA enabled successfully
@@ -435,3 +441,8 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
enterprise:
billing:
topup_successful: Topup successful
subscription_cancelled: Subscription cancelled
pricing_plan_changed: Pricing plan changed
+14 -2
View File
@@ -141,7 +141,6 @@ Rails.application.routes.draw do
post :custom_attributes
get :attachments
get :inbox_assistant
get :reporting_events if ChatwootApp.enterprise?
end
end
@@ -187,7 +186,6 @@ Rails.application.routes.draw do
get :download
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -436,6 +434,20 @@ Rails.application.routes.draw do
end
end
end
namespace :v2 do
resources :accounts, only: [] do
resource :billing, only: [] do
get :credit_grants
get :pricing_plans
get :topup_options
post :topup
post :subscribe
post :cancel_subscription
post :change_pricing_plan
end
end
end
end
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
@@ -1,5 +0,0 @@
class AddNameToWebhooks < ActiveRecord::Migration[7.1]
def change
add_column :webhooks, :name, :string, null: true
end
end
-1
View File
@@ -1230,7 +1230,6 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_22_152158) do
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"]
t.string "name"
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end
@@ -1,30 +0,0 @@
class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_admin_authorization?
before_action :set_reporting_events, only: [:index]
before_action :set_current_page, only: [:index]
def index
@reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
@total_count = @reporting_events.total_count
end
private
def set_reporting_events
@reporting_events = Current.account.reporting_events
.includes(:conversation, :user, :inbox)
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_user_id(params[:user_id])
.filter_by_name(params[:name])
.order(created_at: :desc)
end
def set_current_page
@current_page = (params[:page] || 1).to_i
end
end
@@ -11,10 +11,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
end
def reporting_events
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
@@ -1,5 +1,6 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
@@ -0,0 +1,114 @@
class Enterprise::Api::V2::BillingController < Api::BaseController
before_action :fetch_account
before_action :check_authorization
before_action :validate_topup_amount, only: [:topup]
rescue_from StandardError, with: :render_error
rescue_from NotImplementedError, with: :render_not_implemented
def credit_grants
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
grants = service.fetch_credit_grants
render json: { credit_grants: grants }
end
def pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def 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 subscribe
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
redirect_url = service.create_subscription_checkout(
pricing_plan_id: params[:pricing_plan_id],
quantity: subscription_quantity
)
render json: { redirect_url: redirect_url }
end
def cancel_subscription
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
result = service.cancel_subscription
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def change_pricing_plan
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
result = service.change_plan(
new_pricing_plan_id: params[:pricing_plan_id],
quantity: params[:quantity]&.to_i
)
if result[:success]
# Include account ID and updated attributes for frontend store update
@account.reload
render json: result.merge(
id: @account.id,
custom_attributes: @account.custom_attributes
)
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
private
def fetch_account
@account = current_user.accounts.find(params[:account_id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def subscription_quantity
[params[:quantity].to_i, 1].max
end
def validate_topup_amount
return if params[:credits].to_i.positive?
render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity
end
def pundit_user
{
user: current_user,
account: @account,
account_user: @current_account_user
}
end
def render_error(exception)
render json: { error: exception.message }, status: :unprocessable_entity
end
def render_not_implemented(exception)
render json: { error: exception.message }, status: :not_implemented
end
end
@@ -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,27 @@ 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']
return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank?
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)
return false if event_type.blank?
Rails.logger.debug { "V2 billing event: #{event_type}" }
event_type.start_with?('v2.')
end
end
@@ -1,6 +1,13 @@
module Enterprise::Account::PlanUsageAndLimits
# Total credits
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
# Response credits breakdown (monthly + topup)
CAPTAIN_RESPONSES_MONTHLY = 'captain_responses_monthly'.freeze
CAPTAIN_RESPONSES_TOPUP = 'captain_responses_topup'.freeze
# Usage tracking
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
CAPTAIN_DOCUMENTS_USAGE = 'captain_documents_usage'.freeze
@@ -16,8 +23,7 @@ module Enterprise::Account::PlanUsageAndLimits
end
def increment_response_usage
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
custom_attributes[CAPTAIN_RESPONSES_USAGE] = (custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0) + 1
save
end
@@ -58,11 +64,12 @@ module Enterprise::Account::PlanUsageAndLimits
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
total_count: total_count,
monthly: (self[:limits][CAPTAIN_RESPONSES_MONTHLY].to_i if type == :responses),
topup: (self[:limits][CAPTAIN_RESPONSES_TOPUP].to_i if type == :responses),
current_available: (total_count - consumed).clamp(0, total_count),
consumed: consumed
}
@@ -96,17 +103,12 @@ module Enterprise::Account::PlanUsageAndLimits
end
def agent_limits
subscribed_quantity = custom_attributes['subscribed_quantity']
subscribed_quantity || get_limits(:agents)
custom_attributes['subscribed_quantity'] || get_limits(:agents)
end
def get_limits(limit_name)
config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT"
return self[:limits][limit_name.to_s] if self[:limits][limit_name.to_s].present?
return GlobalConfig.get(config_name)[config_name] if GlobalConfig.get(config_name)[config_name].present?
ChatwootApp.max_limit
self[:limits][limit_name.to_s].presence || GlobalConfig.get(config_name)[config_name].presence || ChatwootApp.max_limit
end
def validate_limit_keys
@@ -119,7 +121,9 @@ module Enterprise::Account::PlanUsageAndLimits
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
'captain_documents' => { 'type': 'number' },
'captain_responses_monthly' => { 'type': 'number' },
'captain_responses_topup' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
@@ -0,0 +1,14 @@
module Enterprise::Billing::Concerns::BillingIntentWorkflow
extend ActiveSupport::Concern
private
# Execute a billing intent with automatic reserve and commit
def execute_billing_intent(intent_params)
intent = create_billing_intent(intent_params)
reserve_billing_intent(intent['id'])
yield(intent) if block_given?
commit_billing_intent(intent['id'])
intent
end
end
@@ -0,0 +1,24 @@
module Enterprise::Billing::Concerns::PlanDataHelper
extend ActiveSupport::Concern
private
def fetch_plan_version(plan_id)
plan = retrieve_pricing_plan(plan_id)
version = extract_attribute(plan, :latest_version)
raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank?
version
end
def fetch_plan_lookup_key(plan_id)
lookup_key = Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id)
raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless lookup_key
lookup_key
end
def plan_display_name(plan_id)
Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan'
end
end
@@ -0,0 +1,87 @@
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
advanced_search
].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)
disable_all_premium_features
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 '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
@@ -0,0 +1,13 @@
module Enterprise::Billing::Concerns::PlanProvisioningHelper
extend ActiveSupport::Concern
private
def provision_new_plan(new_pricing_plan_id)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
return unless plan_definition
plan_name = plan_definition[:display_name]
enable_plan_specific_features(plan_name) if plan_name.present?
end
end
@@ -0,0 +1,92 @@
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
extend ActiveSupport::Concern
include Enterprise::Billing::Concerns::PlanDataHelper
private
def build_proration_line_items(context, proration_data)
return build_seat_change_line_items(context, proration_data) if seat_only_change?(context)
build_plan_change_line_items(context, proration_data)
end
def seat_only_change?(context)
!context[:plan_changed] && context[:seats_changed]
end
def build_seat_change_line_items(context, proration_data)
[{
amount: (proration_data[:net_amount] * 100).to_i,
description: build_seat_change_description(context),
metadata: build_seat_change_metadata(context, proration_data)
}]
end
def build_plan_change_line_items(context, proration_data)
line_items = []
old_plan_name = plan_display_name(context[:old_plan_id])
new_plan_name = plan_display_name(context[:target_plan_id])
line_items << build_credit_line_item(context, proration_data, old_plan_name) if proration_data[:credit_amount].positive?
line_items << build_charge_line_item(context, proration_data, new_plan_name) if proration_data[:charge_amount].positive?
line_items
end
def build_credit_line_item(context, proration_data, old_plan_name)
{
amount: -(proration_data[:credit_amount] * 100).to_i,
description: credit_description(old_plan_name, context[:old_quantity]),
metadata: credit_metadata(old_plan_name, context[:old_quantity], proration_data[:days_remaining])
}
end
def build_charge_line_item(context, proration_data, new_plan_name)
{
amount: (proration_data[:charge_amount] * 100).to_i,
description: charge_description(new_plan_name, context[:target_quantity]),
metadata: charge_metadata(new_plan_name, context[:target_quantity], proration_data[:days_remaining])
}
end
def credit_description(plan_name, quantity)
"Credit for unused time on #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def charge_description(plan_name, quantity)
"Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
end
def build_seat_change_description(context)
plan_name = plan_display_name(context[:target_plan_id])
change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease'
quantity_diff = (context[:target_quantity] - context[:old_quantity]).abs
"Seat #{change_type} for #{plan_name}: #{context[:old_quantity]}#{context[:target_quantity]} " \
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
end
def base_metadata(type, days_remaining, **additional_fields)
{
type: type,
days_remaining: days_remaining,
billing_version: 'v2'
}.merge(additional_fields)
end
def credit_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity)
end
def charge_metadata(plan_name, quantity, days_remaining)
base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity)
end
def build_seat_change_metadata(context, proration_data)
base_metadata('seat_change', proration_data[:days_remaining],
plan_name: plan_display_name(context[:target_plan_id]),
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
quantity_change: context[:target_quantity] - context[:old_quantity])
end
end
@@ -0,0 +1,67 @@
module Enterprise::Billing::Concerns::StripeV2ClientHelper
extend ActiveSupport::Concern
private
# Stripe client instance with API key
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil))
end
# Pricing Plan Subscriptions
def retrieve_pricing_plan_subscription(subscription_id)
stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id)
end
# Pricing Plans
def retrieve_pricing_plan(pricing_plan_id)
stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id)
end
def retrieve_billing_cadence(cadence_id)
stripe_client.v2.billing.cadences.retrieve(cadence_id)
end
def create_billing_intent(params)
response = Faraday.post('https://api.stripe.com/v2/billing/intents') do |req|
req.headers['Authorization'] = "Bearer #{ENV.fetch('STRIPE_SECRET_KEY', nil)}"
req.headers['Stripe-Version'] = default_stripe_version
req.headers['Content-Type'] = 'application/json'
req.body = params.to_json
end
JSON.parse(response.body)
end
def reserve_billing_intent(billing_intent_id)
stripe_client.v2.billing.intents.reserve(billing_intent_id)
end
def commit_billing_intent(billing_intent_id)
stripe_client.v2.billing.intents.commit(billing_intent_id)
end
# Checkout Sessions (V1 API but used with V2 plans)
def create_checkout_session(params)
Stripe::Checkout::Session.create(params, { stripe_version: checkout_stripe_version })
end
# Credit Grants (V1 API but used with V2)
def retrieve_credit_grant(grant_id)
Stripe::Billing::CreditGrant.retrieve(grant_id)
end
def default_stripe_version
'2025-10-29.preview'
end
def checkout_stripe_version
'2025-10-29.preview;checkout_product_catalog_preview=v1'
end
def extract_attribute(object, key)
return object.public_send(key) if object.respond_to?(key)
object[key.to_s]
end
end
@@ -0,0 +1,41 @@
module Enterprise::Billing::Concerns::SubscriptionDataManager
extend ActiveSupport::Concern
private
def fetch_subscription_metadata
subscription_id = fetch_subscription_id
subscription = retrieve_pricing_plan_subscription(subscription_id)
cadence_id = extract_cadence_id(subscription)
cadence = retrieve_billing_cadence(cadence_id)
next_billing_date = extract_attribute(cadence, :next_billing_date)
store_next_billing_date(next_billing_date)
{
subscription_id: subscription_id,
subscription: subscription,
cadence_id: cadence_id,
cadence: cadence,
next_billing_date: next_billing_date
}
end
def fetch_subscription_id
subscription_id = custom_attribute('stripe_subscription_id')
raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank?
subscription_id
end
def extract_cadence_id(subscription)
cadence_id = extract_attribute(subscription, :billing_cadence)
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
cadence_id
end
def store_next_billing_date(next_billing_date)
update_custom_attributes({ 'next_billing_date' => next_billing_date })
end
end
@@ -1,4 +1,6 @@
class Enterprise::Billing::CreateStripeCustomerService
include Enterprise::Billing::Concerns::PlanFeatureManager
pattr_initialize [:account!]
DEFAULT_QUANTITY = 2
@@ -6,22 +8,11 @@ class Enterprise::Billing::CreateStripeCustomerService
def perform
return if existing_subscription?
raise_config_error unless v2_configs_present?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
update_account_for_v2_billing(customer_id)
enable_plan_specific_features('Hacker')
end
private
@@ -35,22 +26,16 @@ class Enterprise::Billing::CreateStripeCustomerService
customer_id
end
def default_quantity
default_plan['default_quantity'] || DEFAULT_QUANTITY
end
def billing_email
account.administrators.first.email
end
def default_plan
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
@default_plan ||= installation_config.value.first
def v2_configs_present?
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present?
end
def price_id
price_ids = default_plan['price_ids']
price_ids.first
def raise_config_error
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
end
def existing_subscription?
@@ -66,4 +51,23 @@ class Enterprise::Billing::CreateStripeCustomerService
)
subscriptions.data.present?
end
def update_account_for_v2_billing(customer_id)
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
attributes = {
stripe_customer_id: customer_id,
stripe_billing_version: 2
}
if hacker_plan_config&.value.present?
attributes.merge!(
stripe_pricing_plan_id: hacker_plan_config.value,
plan_name: 'Hacker',
subscribed_quantity: DEFAULT_QUANTITY
)
end
account.update!(custom_attributes: attributes)
end
end
@@ -1,30 +1,9 @@
class Enterprise::Billing::HandleStripeEventService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
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
advanced_search
].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
@@ -33,6 +12,8 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'billing.credit_grant.created'
process_credit_grant_created
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -47,7 +28,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
@@ -73,65 +55,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)
@@ -139,18 +78,60 @@ class Enterprise::Billing::HandleStripeEventService
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def default_plan?
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
default_plan = cloud_plans.first || {}
account.custom_attributes['plan_name'] == default_plan['name']
def process_credit_grant_created
grant_id = extract_credit_grant_id(@event.data.object)
return if grant_id.blank?
# 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?
grant_type = extract_grant_type(grant)
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
if grant_type == 'monetary'
service.add_response_topup_credits(amount)
else
service.sync_monthly_response_credits(amount)
end
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 extract_credit_grant_id(grant_object)
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
end
# Enable each feature
account.enable_features(*features) if features.present?
def extract_grant_type(grant)
amount_object = extract_attribute(grant, :amount)
return 'monetary' if amount_object.blank?
type = extract_attribute(amount_object, :type)
return 'monetary' if type.blank?
type
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
amount = extract_attribute(grant, :amount)
return 0 if amount.blank?
custom_pricing_unit = extract_attribute(amount, :custom_pricing_unit)
return 0 if custom_pricing_unit.blank?
value = extract_attribute(custom_pricing_unit, :value)
return 0 if value.blank?
value.to_i
end
def extract_attribute(object, attribute)
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
end
end
@@ -0,0 +1,80 @@
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 response_monthly_credits
account.limits&.[]('captain_responses_monthly').to_i
end
def response_topup_credits
account.limits&.[]('captain_responses_topup').to_i
end
def response_usage
account.custom_attributes&.[]('captain_responses_usage').to_i
end
# Update response credits (monthly/topup with auto-calculation of total)
def update_response_credits(monthly: nil, topup: nil)
# Calculate and update total in limits hash ONLY
return if monthly.nil? && topup.nil?
new_monthly = monthly || response_monthly_credits
new_topup = topup || response_topup_credits
total_credits = new_monthly + new_topup
limits = {
'captain_responses_monthly' => new_monthly,
'captain_responses_topup' => new_topup,
'captain_responses' => total_credits
}
update_limits(limits)
end
def update_limits(updates)
return if updates.blank?
account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s)))
end
def update_custom_attributes(updates)
return if updates.blank?
account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s)))
end
def custom_attribute(key)
account.custom_attributes&.[](key.to_s)
end
def with_locked_account(&)
account.with_lock(&)
end
# Convenient accessors for common attributes
def stripe_customer_id
custom_attribute('stripe_customer_id')
end
def stripe_subscription_id
custom_attribute('stripe_subscription_id')
end
def pricing_plan_id
custom_attribute('stripe_pricing_plan_id')
end
def subscribed_quantity
custom_attribute('subscribed_quantity').to_i
end
end
@@ -0,0 +1,60 @@
class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::BillingIntentWorkflow
include Enterprise::Billing::Concerns::SubscriptionDataManager
# Cancel subscription using Stripe's V2 Billing Intent API
# Creates a deactivate billing intent for the pricing plan subscription
# Subscription remains active until the end of the current billing period
#
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
#
def cancel_subscription
with_locked_account do
metadata = fetch_subscription_metadata
intent_params = build_deactivate_params(metadata[:subscription_id], metadata[:cadence_id])
execute_billing_intent(intent_params)
update_account_status(metadata[:next_billing_date])
build_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 build_deactivate_params(subscription_id, cadence_id)
{
cadence: cadence_id,
currency: 'usd',
actions: [{
type: 'deactivate',
deactivate: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: { pricing_plan_subscription: subscription_id }
}
}]
}
end
def update_account_status(next_billing_date)
# Mark subscription as cancelling (will be cancelled at period end)
# Store next_billing_date so the UI can show when the subscription ends
update_custom_attributes({
'subscription_status' => 'cancel_at_period_end',
'subscription_cancelled_at' => Time.current.iso8601,
'subscription_ends_at' => next_billing_date
})
end
def build_success_response
{
success: true,
cancel_at_period_end: true,
message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.'
}
end
end
@@ -0,0 +1,154 @@
class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
include Enterprise::Billing::Concerns::StripeV2ClientHelper
include Enterprise::Billing::Concerns::PlanProvisioningHelper
include Enterprise::Billing::Concerns::BillingIntentWorkflow
include Enterprise::Billing::Concerns::SubscriptionDataManager
include Enterprise::Billing::Concerns::PlanDataHelper
def change_plan(new_pricing_plan_id: nil, quantity: nil)
validation_error = validate_parameters(new_pricing_plan_id, quantity)
return validation_error if validation_error
with_locked_account do
perform_subscription_change(new_pricing_plan_id, quantity)
end
end
private
def validate_parameters(new_pricing_plan_id, quantity)
return { success: false, message: 'Must specify either new_pricing_plan_id or quantity' } if new_pricing_plan_id.nil? && quantity.nil?
return { success: false, message: 'Invalid quantity' } if !quantity.nil? && !quantity.positive?
# Validate customer has a default payment method using common service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_method_validation = payment_service.validate_payment_method
return payment_method_validation unless payment_method_validation.nil?
nil
end
def perform_subscription_change(new_pricing_plan_id, quantity)
change_context = build_change_context(new_pricing_plan_id, quantity)
return no_change_response(change_context) unless change_required?(change_context)
execute_change(change_context)
end
def build_change_context(new_pricing_plan_id, quantity)
old_plan_id = pricing_plan_id
old_quantity = subscribed_quantity
target_plan_id = new_pricing_plan_id || old_plan_id
target_quantity = quantity || old_quantity
{
old_plan_id: old_plan_id,
old_quantity: old_quantity,
target_plan_id: target_plan_id,
target_quantity: target_quantity,
plan_changed: old_plan_id != target_plan_id,
seats_changed: old_quantity != target_quantity
}
end
def change_required?(context)
context[:plan_changed] || context[:seats_changed]
end
def no_change_response(context)
plan_name = plan_display_name(context[:target_plan_id])
{ success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" }
end
def execute_change(context)
# Create and execute billing intent to update the Stripe subscription
intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity])
execute_billing_intent(intent_params)
next_billing_date = custom_attribute('next_billing_date')
proration_data = calculate_proration(
old_plan_id: context[:old_plan_id],
new_plan_id: context[:target_plan_id],
old_quantity: context[:old_quantity],
new_quantity: context[:target_quantity],
next_billing_date: next_billing_date
)
line_items = build_proration_line_items(context, proration_data)
create_and_charge_invoice(line_items)
update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date)
provision_new_plan(context[:target_plan_id]) if context[:plan_changed]
{ success: true, message: 'Plan change successful' }
end
def build_change_plan_params(new_pricing_plan_id, quantity)
metadata = fetch_subscription_metadata
plan_version = fetch_plan_version(new_pricing_plan_id)
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
{
cadence: metadata[:cadence_id],
currency: 'usd',
actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)]
}
end
def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity)
{
type: 'modify',
modify: {
type: 'pricing_plan_subscription_details',
pricing_plan_subscription_details: {
pricing_plan_subscription: subscription_id,
new_pricing_plan: plan_id,
new_pricing_plan_version: plan_version,
component_configurations: [{ lookup_key: lookup_key, quantity: quantity }]
}
}
}
end
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
old_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(old_plan_id)&.dig(:base_fee) || 0.0
new_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(new_plan_id)&.dig(:base_fee) || 0.0
Enterprise::Billing::V2::ProrationCalculator.calculate(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
)
end
def create_and_charge_invoice(line_items)
# Return success with no invoice if no line items (negligible proration)
return { success: true, amount: 0.0, message: 'No charges due to negligible proration' } if line_items.empty?
# Use common invoice payment service
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_service.create_and_pay_invoice(
line_items: line_items,
description: 'Proration charges for plan/seat changes',
metadata: {
account_id: account.id.to_s,
type: 'proration'
}
)
end
def update_account_plan(plan_id, quantity, next_billing_date)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)
plan_name = plan_definition&.dig(:display_name)
update_custom_attributes({
'pricing_plan_id' => plan_id,
'subscribed_quantity' => quantity,
'plan_name' => plan_name,
'next_billing_date' => next_billing_date
})
end
end
@@ -0,0 +1,85 @@
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::StripeV2ClientHelper
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
session = create_checkout_session(checkout_session_params)
session.url
end
private
def validate_params
raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_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
def checkout_session_params
{
customer: stripe_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, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @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
end
@@ -0,0 +1,84 @@
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
# Sync monthly response credits (resets on billing cycle with topup preservation)
def sync_monthly_response_credits(amount)
with_locked_account do
# Preserve topup credits but cap at remaining balance
preserved_topup = preserve_topup_on_reset(
current_topup: response_topup_credits,
new_monthly: amount,
current_usage: response_usage
)
update_response_credits(monthly: amount, topup: preserved_topup)
end
end
# Add topup credits for responses
def add_response_topup_credits(amount)
with_locked_account do
new_topup = response_topup_credits + amount
update_response_credits(topup: new_topup)
end
end
def fetch_credit_grants
return [] if stripe_customer_id.blank?
response = Stripe::Billing::CreditGrant.list(
{ customer: stripe_customer_id, limit: 100 }
)
grants = response.data.map do |grant|
transform_credit_grant(grant)
end
grants.reject { |grant| grant[:credits].zero? }
rescue Stripe::StripeError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
[]
end
private
# Preserve topup credits on monthly reset, capped at remaining balance
# Formula: min(current_topup, max(0, (new_monthly + current_topup) - current_usage))
def preserve_topup_on_reset(current_topup:, new_monthly:, current_usage:)
# Calculate remaining balance after usage
total_after_sync = new_monthly + current_topup
remaining_balance = [total_after_sync - current_usage, 0].max
# Cap topup at remaining balance to avoid over-crediting
[current_topup, remaining_balance].min
end
def transform_credit_grant(grant)
category = grant_attribute(grant, :category)
metadata = grant_attribute(grant, :metadata) || {}
{
id: grant_attribute(grant, :id),
name: grant_attribute(grant, :name),
credits: calculate_grant_credits(category, metadata),
category: category,
source: metadata['source'] || category,
effective_at: parse_timestamp(grant_attribute(grant, :effective_at)),
expires_at: parse_timestamp(grant_attribute(grant, :expires_at)),
voided_at: parse_timestamp(grant_attribute(grant, :voided_at)),
created_at: parse_timestamp(grant_attribute(grant, :created))
}
end
def grant_attribute(grant, key)
grant[key] || grant.public_send(key)
end
def calculate_grant_credits(category, metadata)
return metadata['credits'].to_i if category == 'paid' && metadata['credits']
0
end
def parse_timestamp(timestamp)
return nil unless timestamp
Time.zone.at(timestamp)
end
end
@@ -0,0 +1,138 @@
class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService
# Common service for creating invoices with line items and charging immediately
# Used by both TopupService and ChangePlanService
#
# @param line_items [Array<Hash>] Array of line items: [{ amount:, description:, metadata: }]
# @param description [String] Description for the invoice
# @param currency [String] Currency code (default: 'usd')
# @param metadata [Hash] Metadata for the invoice
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
# Validate that customer has a default payment method
# If no default is set but payment methods exist, automatically set the first one as default
# @return [Hash, nil] Returns error hash if validation fails, nil if success
def validate_payment_method
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(stripe_customer_id)
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
# No default payment method found - try to set one automatically
ensure_default_payment_method_result = ensure_default_payment_method(customer)
return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil?
end
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
{ success: false, message: "Error validating payment method: #{e.message}" }
end
# Ensure a default payment method is set for the customer
# If payment methods exist but none is default, set the first one as default
# @param customer [Stripe::Customer] The Stripe customer object
# @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully
def ensure_default_payment_method(customer)
payment_methods = fetch_customer_payment_methods(customer.id)
return no_payment_methods_error if payment_methods.data.empty?
set_first_payment_method_as_default(customer.id, payment_methods.data.first)
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to set default payment method: #{e.message}")
{ success: false, message: "Error setting default payment method: #{e.message}" }
end
def fetch_customer_payment_methods(customer_id)
Stripe::PaymentMethod.list(customer: customer_id, limit: 100)
end
def no_payment_methods_error
{
success: false,
message: 'No payment methods found. Please add a payment method before making a purchase.'
}
end
def set_first_payment_method_as_default(customer_id, payment_method)
Stripe::Customer.update(
customer_id,
invoice_settings: { default_payment_method: payment_method.id }
)
Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}")
end
# Create invoice with line items and charge immediately
#
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
# @param description [String] Invoice description
# @param currency [String] Currency (default: 'usd')
# @param metadata [Hash] Invoice metadata
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
finalize_and_pay_invoice(invoice.id)
rescue Stripe::StripeError => e
Rails.logger.error("Error creating invoice: #{e.message}")
{ success: false, message: "Error creating invoice: #{e.message}" }
end
private
def create_invoice(customer_id, currency, description, metadata)
Stripe::Invoice.create({
customer: customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description,
metadata: metadata.stringify_keys
})
end
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
line_items.each do |item|
Stripe::InvoiceItem.create({
customer: customer_id,
amount: item[:amount],
currency: currency,
invoice: invoice_id,
description: item[:description],
metadata: (item[:metadata] || {}).stringify_keys
})
end
end
# Finalize invoice and pay it immediately
# @param invoice_id [String] Stripe invoice ID
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def finalize_and_pay_invoice(invoice_id)
# Finalize the invoice
finalized_invoice = Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false }
)
# Pay the invoice immediately if not already paid
if finalized_invoice.status == 'paid'
build_invoice_response(finalized_invoice)
else
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
build_invoice_response(paid_invoice)
end
rescue Stripe::StripeError => e
Rails.logger.error("Error finalizing/paying invoice: #{e.message}")
{ success: false, message: "Error processing payment: #{e.message}" }
end
def build_invoice_response(invoice)
{
success: true,
invoice_id: invoice.id,
invoice_url: invoice.hosted_invoice_url,
amount: invoice.total / 100.0,
status: invoice.status
}
end
end
@@ -0,0 +1,107 @@
module Enterprise::Billing::V2::PlanCatalog
DEFAULT_CURRENCY = 'usd'.freeze
CREDIT_UNIT = 'Credits'.freeze
PLAN_DEFINITIONS = [
{
key: :free,
display_name: '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: 'Startups',
base_fee: 19.0,
monthly_credits: 300,
config_key: 'STRIPE_STARTUP_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
},
{
key: :business,
display_name: 'Business',
base_fee: 39.0,
monthly_credits: 500,
config_key: 'STRIPE_BUSINESS_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
},
{
key: :enterprise,
display_name: 'Enterprise',
base_fee: 99.0,
monthly_credits: 800,
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 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',
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,99 @@
# rubocop:disable Style/ClassAndModuleChildren
module Enterprise
module Billing
module V2
class ProrationCalculator
# Calculate prorated amounts for subscription changes
#
# @param old_plan_price [Float] Price per unit of the old plan
# @param new_plan_price [Float] Price per unit of the new plan
# @param old_quantity [Integer] Old quantity/seats
# @param new_quantity [Integer] New quantity/seats
# @param next_billing_date [String/Time] Next billing date (ISO 8601)
# @return [Hash] { credit_amount:, charge_amount:, net_amount:, days_remaining:, total_days: }
#
def self.calculate(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
new(
old_plan_price: old_plan_price,
new_plan_price: new_plan_price,
old_quantity: old_quantity,
new_quantity: new_quantity,
next_billing_date: next_billing_date
).calculate
end
def initialize(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
@old_plan_price = old_plan_price.to_f
@new_plan_price = new_plan_price.to_f
@old_quantity = old_quantity.to_i
@new_quantity = new_quantity.to_i
@next_billing_date = parse_date(next_billing_date)
@current_date = Time.zone.now
end
def calculate
{
credit_amount: credit_amount,
charge_amount: charge_amount,
net_amount: net_amount,
days_remaining: days_remaining,
total_days: total_days,
proration_factor: proration_factor
}
end
private
def parse_date(date)
return date if date.is_a?(Time) || date.is_a?(DateTime)
Time.zone.parse(date.to_s)
rescue StandardError
raise ArgumentError, "Invalid next_billing_date format: #{date}"
end
# Credit from unused time on old plan
def credit_amount
@credit_amount ||= (@old_plan_price * @old_quantity * proration_factor).round(2)
end
# Charge for new plan for remaining time
def charge_amount
@charge_amount ||= (@new_plan_price * @new_quantity * proration_factor).round(2)
end
# Net amount to charge (positive) or credit (negative)
def net_amount
@net_amount ||= (charge_amount - credit_amount).round(2)
end
# Number of days remaining in current billing period
def days_remaining
@days_remaining ||= (@next_billing_date.to_date - @current_date.to_date).to_i
end
# Total days in the actual billing period (calculate from current cycle)
# This ensures proration factor never exceeds 1.0
def total_days
@total_days ||= begin
# Calculate the total days in this billing cycle
# Assume billing started one month ago from next_billing_date
billing_start = @next_billing_date - 1.month
((@next_billing_date.to_date - billing_start.to_date).to_i)
end
end
# Fraction of billing period remaining
# This value should always be between 0.0 and 1.0
def proration_factor
@proration_factor ||= begin
factor = days_remaining.to_f / total_days
# Ensure factor never exceeds 1.0
[factor, 1.0].min.round(4)
end
end
end
end
end
end
# rubocop:enable Style/ClassAndModuleChildren
@@ -0,0 +1,120 @@
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
include Enterprise::Billing::Concerns::PlanFeatureManager
include Enterprise::Billing::Concerns::PlanProvisioningHelper
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def provision(subscription_id:)
process_subscription(subscription_id)
end
def refresh
return if stripe_subscription_id.blank?
process_subscription(stripe_subscription_id)
end
private
def process_subscription(subscription_id)
# Retrieve pricing plan subscription details from Stripe V2 API
subscription = retrieve_pricing_plan_subscription(subscription_id)
# Check if subscription is canceled
if servicing_status(subscription) == 'canceled'
cancel_subscription
reset_captain_usage
return { pricing_plan_id: nil, quantity: nil }
end
# Extract details from the subscription
pricing_plan_id = extract_pricing_plan_id(subscription)
quantity = extract_subscription_quantity(subscription)
billing_cadence = extract_billing_cadence(subscription)
# Update account with subscription details
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
# Provision the subscription: sync credits and enable features
provision_new_plan(pricing_plan_id) if pricing_plan_id.present?
# Reset usage for the new billing cycle
reset_captain_usage
{ pricing_plan_id: pricing_plan_id, quantity: quantity }
end
def servicing_status(subscription_plan)
extract_attribute(subscription_plan, :servicing_status)
end
def cancel_subscription
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
return if hacker_plan_config.nil?
pricing_plan_id = hacker_plan_config.value
# Update subscription status and plan details
attributes = {
'plan_name': 'Hacker',
'stripe_pricing_plan_id': pricing_plan_id,
'subscribed_quantity': 2,
'stripe_subscription_id': nil,
'billing_cadence': nil,
'subscription_status': 'canceled'
}
update_custom_attributes(attributes)
# Sync credits for Hacker plan (0 credits)
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_response_credits(0)
# Disable all premium features and save
disable_all_premium_features
account.save!
end
def extract_pricing_plan_id(subscription)
extract_attribute(subscription, :pricing_plan)
end
def extract_billing_cadence(subscription)
extract_attribute(subscription, :billing_cadence)
end
def extract_subscription_quantity(_subscription)
# Get quantity from account custom_attributes (set during checkout)
pending_quantity = custom_attribute('pending_subscription_quantity')
return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive?
return subscribed_quantity if subscribed_quantity.positive?
1
end
def update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \
"pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}"
attributes = {
'stripe_subscription_id' => subscription_id,
'subscribed_quantity' => quantity,
'subscription_status' => 'active',
'pending_subscription_quantity' => nil,
'pending_subscription_pricing_plan' => nil,
'billing_cadence' => billing_cadence,
'next_billing_date' => nil,
'pending_stripe_pricing_plan_id' => nil,
'stripe_billing_version' => 2
}
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
end
@@ -0,0 +1,34 @@
module Enterprise::Billing::V2::TopupCatalog
DEFAULT_TOPUPS = [
{ credits: 1000, amount: 20.0 },
{ credits: 2000, amount: 40.0 },
{ credits: 3000, amount: 60.0 },
{ credits: 4000, amount: 80.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,145 @@
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?
plan_validation = validate_subscription_plan
return plan_validation unless plan_validation[:valid]
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?
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_method_validation = payment_service.validate_payment_method
return payment_method_validation.merge(valid: false) if payment_method_validation
{ valid: true, topup_definition: topup_definition }
end
def validate_subscription_plan
plan_name = custom_attribute('plan_name')
# Block topup if no plan or on Hacker plan
if plan_name.blank? || plan_name.downcase == 'hacker'
return {
valid: false,
success: false,
message: 'Top-ups are only available for Startup, Business, and Enterprise plans. Please upgrade your plan to purchase credits.'
}
end
{ valid: true }
end
def process_topup_transaction(credits, amount_cents, currency, amount)
line_items = build_topup_line_items(credits, amount_cents)
invoice_result = charge_topup_invoice(line_items, currency)
return invoice_result unless invoice_result[:success]
credit_grant = create_stripe_credit_grant(amount_cents, currency, credits)
return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant
build_success_response(credits, amount, currency, invoice_result[:invoice_id], credit_grant['id'])
end
def build_topup_line_items(credits, amount_cents)
[{
amount: amount_cents,
description: "Credit Topup: #{credits} credits",
metadata: {
account_id: account.id.to_s,
credits: credits.to_s,
topup: 'true'
}
}]
end
def charge_topup_invoice(line_items, currency)
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
payment_service.create_and_pay_invoice(
line_items: line_items,
description: 'Credit top-up purchase',
currency: currency,
metadata: {
account_id: account.id.to_s,
topup: 'true'
}
)
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 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)
)
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
end
@@ -0,0 +1,57 @@
class Enterprise::Billing::V2::WebhookHandlerService
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def perform(event:)
@event = event
raise StandardError, 'Event is required' if @event.blank?
raise StandardError, '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)
when 'v2.billing.cadence.billed'
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
refresh_account_subscription_details(@event.related_object.id)
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: account).capture_exception
raise
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 = retrieve_pricing_plan_subscription(subscription_id)
return nil unless subscription&.billing_cadence
cadence = retrieve_billing_cadence(subscription.billing_cadence)
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 refresh_account_subscription_details(_cadence_id)
Enterprise::Billing::V2::SubscriptionProvisioningService
.new(account: account)
.refresh
end
end
@@ -1,3 +0,0 @@
json.array! @reporting_events do |reporting_event|
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
end
@@ -1,11 +0,0 @@
json.payload do
json.array! @reporting_events do |reporting_event|
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
end
end
json.meta do
json.count @total_count
json.current_page @current_page
json.total_pages @reporting_events.total_pages
end
@@ -1,12 +0,0 @@
json.id reporting_event.id
json.name reporting_event.name
json.value reporting_event.value
json.value_in_business_hours reporting_event.value_in_business_hours
json.event_start_time reporting_event.event_start_time
json.event_end_time reporting_event.event_end_time
json.account_id reporting_event.account_id
json.inbox_id reporting_event.inbox_id
json.user_id reporting_event.user_id
json.conversation_id reporting_event.conversation_id
json.created_at reporting_event.created_at
json.updated_at reporting_event.updated_at
@@ -28,7 +28,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
Question: #{response.question}
Answer: #{response.answer}
"
if should_show_source?(response)
if response.documentable.present? && response.documentable.try(:external_link)
formatted_response += "
Source: #{response.documentable.external_link}
"
@@ -36,13 +36,4 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
formatted_response
end
def should_show_source?(response)
return false if response.documentable.blank?
return false unless response.documentable.try(:external_link)
# Don't show source if it's a PDF placeholder
external_link = response.documentable.external_link
!external_link.start_with?('PDF:')
end
end
@@ -223,69 +223,6 @@ describe Messages::MessageBuilder do
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
context 'when liquid templates are present in email content' do
let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
it 'processes liquid variables in email content' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, your email is {{contact.email}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
end
it 'does not process liquid in code blocks' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
end
it 'handles broken liquid syntax gracefully' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name} {{invalid}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
end
it 'does not process liquid for incoming messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
message_type: 'incoming'
})
api_channel = create(:channel_api, account: account)
api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
message = described_class.new(user, api_conversation, params).perform
expect(message.content).to eq 'Hello {{contact.name}}'
end
it 'does not process liquid for private messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
end
end
end
end
@@ -36,7 +36,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['status']).to eql('draft')
expect(json_response['payload']['position']).to be(3)
end
@@ -59,28 +59,8 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['position']).to be(3)
end
it 'creates article as draft when status is not provided' do
article_params = {
article: {
category_id: category.id,
description: 'test description',
title: 'DraftTitle',
slug: 'draft-title',
content: 'This is my draft content.',
author_id: agent.id
}
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('DraftTitle')
expect(json_response['payload']['status']).to eql('draft')
expect(json_response['payload']['position']).to be(3)
end
it 'associate to the root article' do
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe 'Webhooks API', type: :request do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com') }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
@@ -49,16 +49,6 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
end
it 'creates webhook with name' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'https://hello.com', name: 'My Webhook' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['name']).to eql 'My Webhook'
end
it 'throws error when invalid url provided' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { account_id: account.id, inbox_id: inbox.id, url: 'javascript:alert(1)' },
@@ -113,12 +103,11 @@ RSpec.describe 'Webhooks API', type: :request do
context 'when it is an authenticated admin user' do
it 'updates webhook' do
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
params: { url: 'https://hello.com', name: 'Another Webhook' },
params: { url: 'https://hello.com' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
end
end
end
@@ -102,146 +102,4 @@ RSpec.describe 'Conversations API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
let(:conversation) { create(:conversation, account: account) }
let(:inbox) { conversation.inbox }
let(:agent) { administrator }
before do
# Create reporting events for this conversation
@event1 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 120,
created_at: 3.hours.ago)
@event2 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'reply_time',
value: 45,
created_at: 2.hours.ago)
@event3 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'resolution',
value: 300,
created_at: 1.hour.ago)
# Create an event for a different conversation (should not be included)
other_conversation = create(:conversation, account: account)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: other_conversation.inbox,
user: agent,
name: 'other_conversation_event',
value: 60)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user with conversation access' do
it 'returns all reporting events for the conversation' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Should return array directly (no pagination)
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
# Check they are sorted by created_at asc (oldest first)
expect(json_response.first['name']).to eq('first_response')
expect(json_response.last['name']).to eq('resolution')
# Verify it doesn't include events from other conversations
event_names = json_response.map { |e| e['name'] }
expect(event_names).not_to include('other_conversation_event')
end
it 'returns empty array when conversation has no reporting events' do
conversation_without_events = create(:conversation, account: account)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response).to be_empty
end
end
context 'when agent has limited access' do
let(:limited_agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized for unassigned conversation without permission' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: limited_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns reporting events when agent is assigned to the conversation' do
conversation.update!(assignee: limited_agent)
# Also create inbox member for the agent
create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: limited_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
end
end
context 'when agent has team access' do
let(:team_agent) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
before do
create(:team_member, team: team, user: team_agent)
conversation.update!(team: team)
end
it 'allows accessing conversation reporting events via team membership' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: team_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
end
end
end
end
@@ -1,217 +0,0 @@
require 'rails_helper'
RSpec.describe 'Enterprise Reporting Events API', type: :request do
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/reporting_events",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated normal agent user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
before do
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 120,
created_at: 3.days.ago)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'resolution',
value: 300,
created_at: 2.days.ago)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'reply_time',
value: 45,
created_at: 1.day.ago)
end
it 'fetches reporting events with pagination' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Check structure and pagination
expect(json_response).to have_key('payload')
expect(json_response).to have_key('meta')
expect(json_response['meta']['count']).to eq(3)
# Check events are sorted by created_at desc (newest first)
events = json_response['payload']
expect(events.size).to eq(3)
expect(events.first['name']).to eq('reply_time')
expect(events.last['name']).to eq('first_response')
end
it 'filters reporting events by date range using since and until' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(1)
expect(json_response['payload'].first['name']).to eq('resolution')
end
it 'filters reporting events by inbox_id' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: other_inbox,
user: agent,
name: 'other_inbox_event')
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { inbox_id: inbox.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(3)
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
end
it 'filters reporting events by user_id (agent)' do
other_agent = create(:user, account: account, role: :agent)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: other_agent,
name: 'other_agent_event')
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { user_id: agent.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(3)
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
end
it 'filters reporting events by name' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { name: 'first_response' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(1)
expect(json_response['payload'].first['name']).to eq('first_response')
end
it 'supports combining multiple filters' do
# Create more test data
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 90,
created_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/reporting_events",
params: {
inbox_id: inbox.id,
user_id: agent.id,
name: 'first_response',
since: 4.days.ago.to_time.to_i.to_s,
until: Time.zone.now.to_time.to_i.to_s
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(2)
expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
end
context 'with pagination' do
before do
# Create more events to test pagination
30.times do |i|
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: "event_#{i}",
created_at: i.hours.ago)
end
end
it 'returns 25 events per page by default' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].size).to eq(25)
expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
expect(json_response['meta']['current_page']).to eq(1)
end
it 'supports page navigation' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { page: 2 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].size).to eq(8) # Remaining events
expect(json_response['meta']['current_page']).to eq(2)
end
end
end
end
end
@@ -205,8 +205,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
},
'conversation' => {},
'captain' => {
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => nil, 'topup' => nil },
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
'monthly' => 0, 'topup' => 0 }
},
'non_web_inboxes' => {}
}
@@ -2,24 +2,34 @@ require 'rails_helper'
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
describe 'POST /enterprise/webhooks/stripe' do
let(:params) { { content: 'hello' } }
let(:payload) { { type: 'customer.subscription.updated', content: 'hello' }.to_json }
let(:event_object) { instance_double(Stripe::Event, type: 'customer.subscription.updated') }
around do |example|
ENV['STRIPE_WEBHOOK_SECRET'] = 'test_secret'
ENV['STRIPE_WEBHOOK_SECRET_V2'] = 'test_secret_v2'
example.run
ENV.delete('STRIPE_WEBHOOK_SECRET')
ENV.delete('STRIPE_WEBHOOK_SECRET_V2')
end
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
handle_stripe = double
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
allow(Stripe::Webhook).to receive(:construct_event).and_return(event_object)
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)
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(handle_stripe).to have_received(:perform).with(event: event_object)
end
it 'returns a bad request if the headers are missing' do
post '/enterprise/webhooks/stripe', params: params
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json' }
expect(response).to have_http_status(:bad_request)
end
it 'returns a bad request if the headers are invalid' do
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
allow(Stripe::Webhook).to receive(:construct_event).and_raise(Stripe::SignatureVerificationError.new('Invalid signature', 'sig'))
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
expect(response).to have_http_status(:bad_request)
end
end
@@ -1,139 +1,99 @@
require 'rails_helper'
describe Enterprise::Billing::CreateStripeCustomerService do
subject(:create_stripe_customer_service) { described_class }
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
let(:hacker_plan_id) { 'price_hacker_random' }
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(
before do
create(
:installation_config,
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{
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
'name' => 'Hacker',
'limits' => {
'captain_responses_monthly' => 5,
'captain_responses_topup' => 0
}
}
]
)
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
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
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
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)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
describe '#perform' do
context 'when account already has an active subscription' do
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
allow(Stripe::Subscription).to receive(:list)
.and_return(Stripe::ListObject.construct_from({ data: ['subscription'] }))
end
context 'when customer has active subscriptions' do
it 'returns without modifying the account or contacting Stripe' do
expect(Stripe::Customer).not_to receive(:create)
expect { service.perform }.not_to(change { account.reload.custom_attributes })
end
end
context 'when v2 billing configuration is missing' do
before do
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy!
end
it 'raises an informative error' do
expect { service.perform }.to raise_error(
StandardError,
'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
)
end
end
context 'when account needs to be upgraded to v2 billing' do
context 'when stripe customer already exists' do
before do
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
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)
allow(Stripe::Customer).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
it 'does not create a new customer but updates custom attributes for v2 billing' do
service.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
}
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_existing',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
context 'when stripe customer does not exist' do
before do
allow(Stripe::Customer).to receive(:create).and_return(stripe_customer_double)
end
it 'creates a stripe customer and updates the account with v2 billing attributes' do
service.perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin.email })
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_new',
'stripe_pricing_plan_id' => hacker_plan_id,
'plan_name' => 'Hacker',
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
'stripe_billing_version' => 2
)
end
end
@@ -317,4 +317,146 @@ 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 'adds credits from Stripe' do
allow(credit_service).to receive(:sync_monthly_response_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',
metadata: { 'credits' => '2000' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 2000)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_123')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_response_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' do
allow(credit_service).to receive(:sync_monthly_response_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',
metadata: { 'credits' => '500' },
amount: OpenStruct.new(
type: 'custom_pricing_unit',
custom_pricing_unit: OpenStruct.new(value: 500)
),
expires_at: nil
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_456')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:sync_monthly_response_credits).with(500)
end
end
context 'when handling monetary type credit grant' do
it 'adds credits from monetary grant' do
allow(credit_service).to receive(:add_response_topup_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',
metadata: { 'credits' => '1000' },
amount: OpenStruct.new(
type: 'monetary',
monetary: OpenStruct.new(
currency: 'usd',
value: 1000
)
),
expires_at: Time.current
)
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('credgr_test_monetary')
.and_return(api_grant_response)
stripe_event_service.new.perform(event: event)
expect(credit_service).to have_received(:add_response_topup_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(Stripe::Billing::CreditGrant).to receive(:retrieve)
.with('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
+5 -7
View File
@@ -1,13 +1,11 @@
FactoryBot.define do
factory :reporting_event do
name { 'first_response' }
name { 'MyString' }
value { 1.5 }
value_in_business_hours { 1 }
account
inbox { association :inbox, account: account }
user { association :user, account: account }
conversation { association :conversation, account: account, inbox: inbox }
event_start_time { 2.hours.ago }
event_end_time { 1.hour.ago }
account_id { 1 }
inbox_id { 1 }
user_id { 1 }
conversation_id { 1 }
end
end
-1
View File
@@ -3,7 +3,6 @@ FactoryBot.define do
account_id { 1 }
inbox_id { 1 }
url { 'https://api.chatwoot.com' }
name { 'My Webhook' }
subscriptions do
%w[
conversation_status_changed
+6 -9
View File
@@ -41,31 +41,28 @@ RSpec.describe ApplicationMailbox do
describe 'Support' do
let!(:channel_email) { create(:channel_email) }
it 'routes support emails to Reply Mailbox when mail is to channel email' do
it 'routes support emails to Support Mailbox when mail is to channel email' do
# this email is hardcoded in the support.eml, that's why we are updating this
# With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(email: 'care@example.com')
dbl = double
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
it 'routes support emails to Reply Mailbox when mail is to channel forward to email' do
it 'routes support emails to Support Mailbox when mail is to channel forward to email' do
# this email is hardcoded in the support.eml, that's why we are updating this
# With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(forward_to_email: 'care@example.com')
dbl = double
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
it 'routes support emails to Reply Mailbox with cc email' do
# With NewConversationStrategy, all channel emails route to ReplyMailbox
it 'routes support emails to Support Mailbox with cc email' do
channel_email.update(email: 'test@example.com')
dbl = double
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route reply_cc_mail
end
-49
View File
@@ -264,54 +264,5 @@ RSpec.describe Imap::ImapMailbox do
expect(conversation.additional_attributes['in_reply_to']).to eq(multiple_in_reply_to_mail.in_reply_to.first)
end
end
context 'when a reply to a conversation started by an agent' do
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail_with_fallback_reference) do
# Simulate an email reply with a reference that matches FALLBACK_PATTERN
reference_id = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
create_inbound_email_from_mail(
from: 'email@gmail.com',
to: 'imap@gmail.com',
subject: 'Re: Agent started conversation',
references: [reference_id]
)
end
it 'appends email to the existing conversation using FALLBACK_PATTERN' do
expect(agent_conversation.messages.size).to eq(0)
class_instance.process(reply_mail_with_fallback_reference.mail, channel)
agent_conversation.reload
expect(agent_conversation.messages.size).to eq(1)
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_fallback_reference.mail.from)
end
end
context 'when references contain both message and fallback patterns' do
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail_with_multiple_references) do
# Multiple references including both patterns
fallback_reference = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
other_reference = 'some-other-message-id@example.com'
create_inbound_email_from_mail(
from: 'email@gmail.com',
to: 'imap@gmail.com',
subject: 'Re: Multiple references',
references: [other_reference, fallback_reference]
)
end
it 'finds conversation using fallback pattern when message lookup fails' do
expect(agent_conversation.messages.size).to eq(0)
class_instance.process(reply_mail_with_multiple_references.mail, channel)
agent_conversation.reload
expect(agent_conversation.messages.size).to eq(1)
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_multiple_references.mail.from)
end
end
end
end
-443
View File
@@ -248,448 +248,5 @@ RSpec.describe ReplyMailbox do
)
end
end
context 'with references header' do
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation_1) do
create(
:conversation,
assignee: agent,
inbox: email_channel.inbox,
account: account,
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
)
end
before do
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
end
context 'with message-specific pattern in references' do
before do
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
end
it 'finds conversation from references header with message pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation fallback pattern in references' do
before do
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
end
it 'finds conversation from references header with fallback pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with multiple references including conversation pattern' do
before do
reply_mail_with_references.mail['References'] = [
'<some-random-message-id@gmail.com>',
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
'<another-random-message-id@outlook.com>'
].join("\r\n ")
end
it 'finds conversation from any reference in the chain' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with message source_id in references' do
before do
conversation_1.messages.create!(
source_id: 'original-message-id@test.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
end
it 'finds conversation from message source_id in references' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation from different channel in references' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
assignee: agent,
inbox: other_email_channel.inbox,
account: account
)
end
before do
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
end
it 'does not use conversation from different channel' do
described_class.receive reply_mail_with_references
expect(other_conversation.messages.count).to eq(0)
end
end
end
end
describe 'when a chatwoot notification email is received' do
let(:account) { create(:account) }
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
let(:described_subject) { described_class.receive notification_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'add mail as a new ticket in the email inbox' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
describe 'covers email address format' do
before do
described_class.receive support_in_reply_to_mail
end
it 'creates contact with proper email address' do
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
expect(conversation.contact.email).to eq('sony@chatwoot.com')
end
end
describe 'covers basic ticket creation' do
before do
described_subject
end
it 'create the conversation in the inbox of the email channel' do
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
end
it 'create a new contact as the sender of the email' do
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
it 'add the mail content as new message on the conversation' do
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
end
it 'add the attachments' do
expect(conversation.messages.last.attachments.count).to eq(2)
end
it 'have proper content_attributes with details of email' do
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
end
it 'set proper content_type' do
expect(conversation.messages.last.content_type).to eq('incoming_email')
end
end
describe 'email with references header' do
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
let(:described_subject) { described_class.receive mail_with_references }
before do
# reuse the existing channel_email that's already set to 'care@example.com'
described_subject
end
it 'includes references in the message content_attributes' do
message = conversation.messages.last
email_attributes = message.content_attributes['email']
expect(email_attributes['references']).to be_present
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
end
it 'includes references in serialized email attributes' do
message = conversation.messages.last
expect(message.content_attributes['email'].keys).to include('references')
end
end
describe 'Sender without name' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new contact with the email' do
described_subject
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'Sender with upcase mail address' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new inbox with the email case insensitive' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
end
end
describe 'handle inbox contacts' do
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
context 'with uppercase reply-to' do
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
end
end
describe 'group email sender' do
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
let(:described_subject) { described_class.receive group_sender_support_mail }
before do
# this email is hardcoded eml fixture file that's why we are updating this
channel_email.email = 'support@chatwoot.com'
channel_email.save!
end
it 'create new contact with original sender' do
described_subject
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'when mail has in reply to email' do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:described_subject) { described_class.receive reply_mail_without_uuid }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
before do
email_channel
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
end
it 'create channel with reply to mail' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
end
it 'append message to email conversation with same in reply to' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(1)
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
described_class.receive reply_mail_without_uuid
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(2)
end
end
describe 'Sender with reply_to email address' do
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
it 'prefer reply-to over from address' do
email_channel
described_class.receive reply_to_mail
conversation_1 = Conversation.last
email = conversation_1.messages.last.content_attributes['email']
expect(reply_to_mail.mail['From'].value).to be_present
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
end
end
describe 'when mail part is not present' do
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
let(:described_subject) { described_class.receive support_mail }
it 'Considers raw html mail body' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
<<~BODY.chomp
Hi,
We are providing you platform from here you can sell paid posts on your website.
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
Skype: live:.cid.something
[]
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
end
it 'Considers only text body' do
described_class.receive only_text
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('text only mail')
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
end
it 'Considers only html body' do
described_class.receive only_html
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq(
<<~BODY.chomp
This is html only mail
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
end
it 'Considers only attachments' do
described_class.receive only_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to be_nil
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
end
it 'Considers html and attachments' do
described_class.receive html_and_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
end
end
describe 'when BCC processing is disabled for account' do
before do
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
end
it 'does not process BCC-only emails' do
bcc_mail = create_inbound_email_from_fixture('support.eml')
bcc_mail.mail['to'] = nil
bcc_mail.mail['bcc'] = 'care@example.com'
described_class.receive bcc_mail
expect(conversation.present?).to be(false)
end
end
end
end
+352
View File
@@ -0,0 +1,352 @@
require 'rails_helper'
RSpec.describe SupportMailbox do
include ActionMailbox::TestHelper
describe 'when a chatwoot notification email is received' do
let(:account) { create(:account) }
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
let(:described_subject) { described_class.receive notification_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'add mail as a new ticket in the email inbox' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
describe 'covers email address format' do
before do
described_class.receive support_in_reply_to_mail
end
it 'creates contact with proper email address' do
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
expect(conversation.contact.email).to eq('sony@chatwoot.com')
end
end
describe 'covers basic ticket creation' do
before do
described_subject
end
it 'create the conversation in the inbox of the email channel' do
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
end
it 'create a new contact as the sender of the email' do
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
it 'add the mail content as new message on the conversation' do
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
end
it 'add the attachments' do
expect(conversation.messages.last.attachments.count).to eq(2)
end
it 'have proper content_attributes with details of email' do
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
end
it 'set proper content_type' do
expect(conversation.messages.last.content_type).to eq('incoming_email')
end
end
describe 'email with references header' do
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
let(:described_subject) { described_class.receive mail_with_references }
before do
# reuse the existing channel_email that's already set to 'care@example.com'
described_subject
end
it 'includes references in the message content_attributes' do
message = conversation.messages.last
email_attributes = message.content_attributes['email']
expect(email_attributes['references']).to be_present
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
end
it 'includes references in serialized email attributes' do
message = conversation.messages.last
expect(message.content_attributes['email'].keys).to include('references')
end
end
describe 'Sender without name' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new contact with the email' do
described_subject
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'Sender with upcase mail address' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new inbox with the email case insensitive' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
end
end
describe 'handle inbox contacts' do
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
context 'with uppercase reply-to' do
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
end
end
describe 'group email sender' do
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
let(:described_subject) { described_class.receive group_sender_support_mail }
before do
# this email is hardcoded eml fixture file that's why we are updating this
channel_email.email = 'support@chatwoot.com'
channel_email.save!
end
it 'create new contact with original sender' do
described_subject
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'when mail has in reply to email' do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:described_subject) { described_class.receive reply_mail_without_uuid }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
before do
email_channel
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
end
it 'create channel with reply to mail' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
end
it 'append message to email conversation with same in reply to' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(1)
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
described_class.receive reply_mail_without_uuid
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(2)
end
end
describe 'Sender with reply_to email address' do
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
it 'prefer reply-to over from address' do
email_channel
described_class.receive reply_to_mail
conversation_1 = Conversation.last
email = conversation_1.messages.last.content_attributes['email']
expect(reply_to_mail.mail['From'].value).to be_present
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
end
end
describe 'when mail part is not present' do
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
let(:described_subject) { described_class.receive support_mail }
it 'Considers raw html mail body' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
<<~BODY.chomp
Hi,
We are providing you platform from here you can sell paid posts on your website.
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
Skype: live:.cid.something
[]
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
end
it 'Considers only text body' do
described_class.receive only_text
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('text only mail')
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
end
it 'Considers only html body' do
described_class.receive only_html
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq(
<<~BODY.chomp
This is html only mail
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
end
it 'Considers only attachments' do
described_class.receive only_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to be_nil
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
end
it 'Considers html and attachments' do
described_class.receive html_and_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
end
end
describe 'when BCC processing is disabled for account' do
before do
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
end
it 'does not process BCC-only emails' do
bcc_mail = create_inbound_email_from_fixture('support.eml')
bcc_mail.mail['to'] = nil
bcc_mail.mail['bcc'] = 'care@example.com'
expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found')
end
end
end
end
@@ -1,133 +0,0 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinder do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when receiver uuid strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
end
end
context 'when in_reply_to strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
end
end
context 'when references strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
end
end
context 'when no strategy finds conversation' do
# With NewConversationStrategy in default strategies, this scenario only happens
# when using custom strategies that exclude NewConversationStrategy
let(:finding_strategies) do
[
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
Mailbox::ConversationFinderStrategies::ReferencesStrategy
]
end
it 'returns nil' do
finder = described_class.new(mail, strategies: finding_strategies)
expect(finder.find).to be_nil
end
it 'logs that no conversation was found' do
allow(Rails.logger).to receive(:error)
finder = described_class.new(mail, strategies: finding_strategies)
finder.find
expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
end
end
context 'with custom strategies' do
let(:custom_strategy_class) do
Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
def find
# Always return nil for testing
nil
end
end
end
it 'uses provided strategies instead of defaults' do
finder = described_class.new(mail, strategies: [custom_strategy_class])
expect(finder.find).to be_nil
end
end
context 'with strategy execution order' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
# Set up mail so all strategies could match
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
end
it 'returns conversation from first matching strategy' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
result = finder.find
expect(result).to eq(conversation)
# Should only log the first strategy that succeeded (ReceiverUuidStrategy)
expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
end
end
end
end
@@ -1,118 +0,0 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when in_reply_to has message-specific pattern' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to has conversation fallback pattern' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to matches message source_id' do
let(:message) do
conversation.messages.create!(
source_id: 'original-message-id@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
end
before do
message # Create the message
mail.in_reply_to = 'original-message-id@example.com'
end
it 'finds conversation from message source_id' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to has multiple values' do
let(:message) do
conversation.messages.create!(
source_id: 'message-123@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Test message'
)
end
before do
message # Create the message
mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
end
it 'finds conversation from any in_reply_to value' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to is blank' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when in_reply_to does not match any pattern or source_id' do
before do
mail.in_reply_to = 'random-message-id@gmail.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when UUID exists but conversation does not' do
before do
mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with malformed in_reply_to pattern' do
before do
mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
end
end
@@ -1,161 +0,0 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:mail) { Mail.new }
before do
mail.to = [email_channel.email]
mail.from = 'sender@example.com'
mail.subject = 'Test Subject'
mail.message_id = '<test@example.com>'
end
describe '#find' do
context 'when channel is found' do
context 'with new contact' do
it 'builds a new conversation with new contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.inbox).to eq(email_channel.inbox)
expect(conversation.account).to eq(account)
end.to not_change(Conversation, :count) # No conversation created yet
.and change(Contact, :count).by(1) # Contact is created
.and change(ContactInbox, :count).by(1)
end
it 'sets conversation attributes correctly' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
end
it 'sets contact attributes correctly' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.contact.email).to eq('sender@example.com')
expect(conversation.contact.name).to eq('sender')
end
end
context 'with existing contact' do
let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
before do
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
end
it 'builds conversation with existing contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.contact).to eq(existing_contact)
end.to not_change(Conversation, :count) # No conversation created yet
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
end
end
context 'when mail has In-Reply-To header' do
before do
mail['In-Reply-To'] = '<previous-message@example.com>'
end
it 'stores in_reply_to in additional_attributes' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['in_reply_to']).to eq('<previous-message@example.com>')
end
end
context 'when mail is auto reply' do
before do
mail['X-Autoreply'] = 'yes'
end
it 'marks conversation as auto_reply' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['auto_reply']).to be true
end
end
context 'when sender has name in From header' do
before do
mail.from = 'John Doe <john@example.com>'
end
it 'uses sender name from mail' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.contact.name).to eq('John Doe')
end
end
end
context 'when channel is not found' do
before do
mail.to = ['nonexistent@example.com']
end
it 'returns nil' do
strategy = described_class.new(mail)
expect do
result = strategy.find
expect(result).to be_nil
end.not_to change(Conversation, :count)
end
end
context 'when contact creation fails' do
before do
builder = instance_double(ContactInboxWithContactBuilder)
allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
end
it 'rolls back the transaction' do
strategy = described_class.new(mail)
expect do
strategy.find
end.to raise_error(ActiveRecord::RecordInvalid)
.and not_change(Conversation, :count)
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
end
end
context 'when conversation creation fails' do
before do
# Make conversation build fail with invalid attributes
allow(Conversation).to receive(:new).and_return(Conversation.new)
end
it 'returns invalid conversation object' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true)
expect(conversation.valid?).to be(false)
end
end
end
end
@@ -1,99 +0,0 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when mail has valid reply+uuid format' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns the conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when mail has uppercase UUID' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
end
it 'returns the conversation (case-insensitive matching)' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when mail has multiple recipients with valid UUID' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
end
it 'extracts UUID from any recipient' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when UUID does not exist in database' do
before do
mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail has no recipients' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has malformed UUID' do
before do
mail.to = 'reply+not-a-valid-uuid@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has no reply+ prefix' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has additional text after UUID' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
end
it 'returns nil (UUID must be exact)' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
end
end
@@ -1,208 +0,0 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when references has message-specific pattern' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references has conversation fallback pattern' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = 'test@example.com'
mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references matches message source_id' do
let(:message) do
conversation.messages.create!(
source_id: 'original-message-id@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
end
before do
message # Create the message
mail.to = 'test@example.com'
mail.references = 'original-message-id@example.com'
end
it 'finds conversation from message source_id' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references has multiple values' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = [
'some-random-message@gmail.com',
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
'another-message@outlook.com'
]
end
it 'finds conversation from any reference' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references is blank' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when references does not match any pattern or source_id' do
before do
mail.references = 'random-message-id@gmail.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with channel validation' do
context 'when conversation belongs to the correct channel' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when conversation belongs to a different channel' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
inbox: other_email_channel.inbox,
account: account
)
end
before do
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
# Mail is addressed to test@example.com but references conversation from other@example.com
mail.to = 'test@example.com'
mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
end
it 'returns nil (prevents cross-channel hijacking)' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when channel cannot be determined from mail' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'unknown@example.com' # Email not associated with any channel
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail has multiple recipients including correct channel' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = ['other@example.com', 'test@example.com']
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'finds the correct channel and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
end
context 'when UUID exists but conversation does not' do
before do
mail.to = 'test@example.com'
mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with malformed references pattern' do
before do
mail.references = 'conversation/not-a-uuid/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when first reference fails channel validation but second succeeds' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
inbox: other_email_channel.inbox,
account: account
)
end
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = 'test@example.com'
mail.references = [
'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
]
end
it 'skips invalid reference and returns conversation from valid reference' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
end
end
-6
View File
@@ -248,9 +248,3 @@ contact_conversations_response:
$ref: ./resource/contact_conversations_response.yml
contactable_inboxes_response:
$ref: ./resource/contactable_inboxes_response.yml
reporting_event:
$ref: ./resource/reporting_event.yml
reporting_event_meta:
$ref: ./resource/reporting_event_meta.yml
reporting_events_list_response:
$ref: ./resource/reporting_events_list_response.yml
@@ -4,9 +4,6 @@ properties:
type: string
description: The url where the events should be sent
example: https://example.com/webhook
name:
type: string
description: The name of the webhook
subscriptions:
type: array
items:
@@ -1,47 +0,0 @@
type: object
properties:
id:
type: number
description: ID of the reporting event
name:
type: string
description: Name of the event (e.g., first_response, resolution, reply_time)
value:
type: number
format: double
description: Value of the metric in seconds
value_in_business_hours:
type: number
format: double
description: Value of the metric in seconds, calculated only for business hours
event_start_time:
type: string
format: date-time
description: The timestamp when the event started
event_end_time:
type: string
format: date-time
description: The timestamp when the event ended
account_id:
type: number
description: ID of the account
conversation_id:
type: number
nullable: true
description: ID of the conversation
inbox_id:
type: number
nullable: true
description: ID of the inbox
user_id:
type: number
nullable: true
description: ID of the user/agent
created_at:
type: string
format: date-time
description: The timestamp when the reporting event was created
updated_at:
type: string
format: date-time
description: The timestamp when the reporting event was last updated
@@ -1,11 +0,0 @@
type: object
properties:
count:
type: integer
description: Total number of reporting events
current_page:
type: integer
description: Current page number
total_pages:
type: integer
description: Total number of pages

Some files were not shown because too many files have changed in this diff Show More