Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma ce8f40debb remove stripe v2 client 2025-11-07 03:37:14 +05:30
Tanmay Deep Sharma 5ca8aec7c3 default plan is hacker plan for customer creation 2025-11-07 02:33:49 +05:30
Tanmay Deep Sharma 4b8b33c4fd stripe_billing_version should be integer two 2025-11-07 02:29:37 +05:30
Tanmay Deep Sharma b7035de400 fix webhook handler spec 2025-11-07 00:05:05 +05:30
Tanmay Deep Sharma cec1a64d6f fix customer creation flow 2025-11-07 00:02:11 +05:30
Tanmay Deep Sharma 8b0caa6909 update pricing plan credits and topup 2025-11-06 19:40:08 +05:30
Tanmay Deep Sharma 56160bfc63 fix rspec for handle stripe event service rspec 2025-11-06 18:56:30 +05:30
Tanmay Deep Sharma abca836f0c revert gitignore local change 2025-11-06 18:37:42 +05:30
Tanmay Deep Sharma 64640092aa Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2 2025-11-06 18:10:37 +05:30
Tanmay Deep Sharma d478f6e42e fxi the subscription provision service spec 2025-11-06 18:10:19 +05:30
Sivin VargheseandGitHub ea34071553 Merge branch 'develop' into feature/stripe_v2 2025-11-06 16:36:14 +05:30
Sivin VargheseandGitHub ba8df900e3 feat: Enhance button interactions (#12738) 2025-11-06 16:24:05 +05:30
Tanmay Deep Sharma fb86b1354a make stripe billing version consistent 2025-11-06 15:31:06 +05:30
Tanmay Deep Sharma ab28802d5a update gemfile stripe version to alpha 2025-11-06 15:26:53 +05:30
Tanmay Deep Sharma f6ec3f3a72 use stripe gem beta version 2025-11-06 14:06:48 +05:30
9b75d9bd1b fix: Add empty line before signature in compose conversation editor (#12702)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-11-06 14:05:52 +05:30
Shivam MishraandGitHub ec6c3b3571 feat: allow bots to handle campaigns when sender_id is nil (#12805) 2025-11-06 14:00:47 +05:30
Sivin VargheseandGitHub 48ba273730 fix: Invalid image URL issue in Help Center articles (#12806) 2025-11-06 13:53:31 +05:30
Tanmay Deep Sharma d51148951f use stripe gem beta version 2025-11-06 13:38:47 +05:30
Tanmay Deep Sharma 79bb3d0ef3 update spec for handle stripe event 2025-11-06 03:38:09 +05:30
Tanmay Deep Sharma 7418882a4f make codebase a bit more modular 2025-11-06 03:32:43 +05:30
Tanmay Deep Sharma d38bd5139e set strip billing version in custom attributes 2025-11-06 02:07:44 +05:30
Tanmay Deep Sharma fcc6973245 remove pricing plan creation 2025-11-06 02:02:35 +05:30
Tanmay Deep Sharma 2590c57893 remove un-necessary serialisation 2025-11-05 23:52:22 +05:30
Tanmay Deep Sharma f225e2fcd8 add credit sync job 2025-11-05 23:32:07 +05:30
Tanmay Deep Sharma b626e0cbf1 use account limits to store the captain limits 2025-11-05 16:41:01 +05:30
Tanmay Deep Sharma 35f42e71f6 fix cursor review comments 2025-11-05 13:16:36 +05:30
Tanmay Deep Sharma eb04882e5a remove credit transactions table and use custome attributes instead 2025-11-05 12:37:23 +05:30
Tanmay Deep Sharma e511527f32 Merge remote-tracking branch 'origin/develop' into feature/stripe_v2 2025-11-04 11:25:28 +05:30
Tanmay Deep Sharma ce5a859a4f create and pay an invoice on subscription change 2025-10-30 15:52:30 +05:30
Tanmay Deep Sharma 94985bbc77 remove update subscription flow overall 2025-10-29 21:37:06 +05:30
Tanmay Deep Sharma f31d9a3e76 store next billing dates 2025-10-29 20:30:16 +05:30
Tanmay Sharma 79082bea58 return failure in case event is blank 2025-10-29 17:48:09 +05:30
Tanmay Sharma b9f68fc839 do not deduct or refund credits for audio transactions 2025-10-29 17:46:39 +05:30
Tanmay Sharma e10ef03490 fix rspec for topup 2025-10-29 17:28:03 +05:30
Tanmay Deep Sharma 6f9f2c32c9 handle cancellation properly 2025-10-29 15:50:12 +05:30
Tanmay Deep Sharma f999112671 fix rspec 2025-10-29 03:24:12 +05:30
Tanmay Deep Sharma be37c5b80c sanity changes 2025-10-29 03:06:34 +05:30
Tanmay Deep Sharma 3970149b06 remove resume feature 2025-10-28 22:45:57 +05:30
Tanmay Deep Sharma 9c33ce50b3 Merge remote-tracking branch 'origin/develop' into feature/stripe_v2 2025-10-28 22:25:19 +05:30
Tanmay Deep Sharma 36a2412f42 support changing pricing plans 2025-10-28 22:22:48 +05:30
Tanmay Sharma 3ba4340042 remove update subscription flow 2025-10-27 14:16:39 +05:30
Tanmay Sharma 411048715d self review backend changes 2025-10-27 02:17:43 +05:30
Tanmay Sharma 9a7a2486e4 Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2 2025-10-27 00:43:44 +05:30
Tanmay Sharma 39af360a18 implement checkout endpoint for subscription in a better way 2025-10-27 00:28:52 +05:30
Tanmay Sharma ac1743514b use env variable for stripe v2 2025-10-16 16:37:52 +05:30
Sivin VargheseandGitHub c1df37b41d Merge branch 'develop' into feature/stripe_v2 2025-10-14 19:23:56 +05:30
Tanmay Sharma 7c3b07c507 code cleanup 2025-10-13 21:06:05 +05:30
Tanmay Sharma 62203da528 fix up extra env 2025-10-13 13:29:00 +05:30
Tanmay Sharma 029294e16b Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2 2025-10-12 17:26:53 +05:30
Tanmay Sharma 2c095bed86 fix spec rubcop 2025-10-12 17:26:07 +05:30
Tanmay Deep SharmaandGitHub 7c6508dd04 Merge branch 'develop' into feature/stripe_v2 2025-10-12 15:47:27 +04:00
Tanmay Sharma cef23891af cleanup code, remove unnecessary 2025-10-12 17:02:16 +05:30
Tanmay Sharma 19a851c01d cleanup code, remove unnecessary 2025-10-11 11:56:16 +04:00
Tanmay Sharma a22e11c810 cleanup code 2025-10-11 10:25:58 +04:00
Tanmay Sharma efe6e64dcd cleanup code 2025-10-11 10:22:46 +04:00
Tanmay Deep Sharma d6f5c627eb fix rspec and customer subscription process 2025-10-10 13:03:27 +02:00
Tanmay Sharma 51b4a8f9fc cleanup 2025-10-09 17:38:27 +02:00
Tanmay Sharma 1268b2cf55 fix schema file 2025-10-09 16:24:32 +02:00
Tanmay Sharma 125ae5523a fix stripe v2 calls 2025-10-09 09:52:56 +02:00
Tanmay Sharma 59f60936a7 fix migration files 2025-10-09 08:32:13 +02:00
Tanmay Deep Sharma 216a2c0e73 do not disable rubocop 2025-10-09 01:04:47 +02:00
Tanmay Deep Sharma 708296a746 code clean up 2025-10-09 00:37:55 +02:00
Tanmay Deep Sharma b9168664f7 stripe v2 fix meter usage 2025-10-08 23:29:31 +02:00
Tanmay Deep Sharma 01cd7b878d stripe v2 init 2025-10-08 10:38:38 +02:00
Tanmay Deep Sharma acb2816826 stripe v2 init 2025-10-08 10:11:23 +02:00
98 changed files with 2676 additions and 4884 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
@@ -1,5 +1,6 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
<ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost
slate
sm
class="!rounded-full"
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
:class="{
'bg-n-alpha-2': isContactSidebarOpen,
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
}"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost
slate
class="!rounded-full"
:class="{
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
}"
sm
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
:class="{
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
isCopilotPanelOpen,
}"
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</ButtonGroup>
</template>
@@ -21,6 +21,10 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
@@ -100,6 +104,10 @@ watch(
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -14,6 +14,7 @@ import {
} from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
:disabled="!articleId"
@click="previewArticle"
/>
<div class="flex items-center">
<ButtonGroup class="flex items-center">
<Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing"
:disabled="
status === ARTICLE_STATUSES.PUBLISHED ||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
icon="i-lucide-chevron-down"
size="sm"
:disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu"
/>
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
/>
</OnClickOutside>
</div>
</div>
</ButtonGroup>
</div>
</div>
</div>
@@ -92,7 +92,6 @@ const setSignature = () => {
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
// Added this watch to dynamically set signature on target inbox change.
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/>
<AttachmentPreviews
@@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
});
const { t } = useI18n();
@@ -90,6 +91,10 @@ const replaceText = async message => {
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
@@ -36,6 +36,7 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
noAnimation: { type: Boolean, default: false },
});
const slots = useSlots();
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium',
lg: 'text-base',
},
clickAnimation: {
xs: 'active:enabled:scale-[0.97]',
sm: 'active:enabled:scale-[0.97]',
md: 'active:enabled:scale-[0.98]',
lg: 'active:enabled:scale-[0.98]',
},
justify: {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
},
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
};
const variantClasses = computed(() => {
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
return classes.join(' ');
});
const animationClasses = computed(() => {
return props.noAnimation
? ''
: STYLE_CONFIG.clickAnimation[computedSize.value];
});
</script>
<template>
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
[STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true,
[animationClasses]: true,
[STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly,
}"
@@ -0,0 +1,20 @@
<script setup>
defineProps({
noAnimation: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
:class="
noAnimation
? ''
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
"
>
<slot />
</div>
</template>
@@ -96,6 +96,7 @@ watch(
:label="selectedLabel"
trailing-icon
:disabled="disabled"
no-animation
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{
focused: open,
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -53,14 +54,17 @@ const toggleSidebar = () => {
v-if="showCopilotLauncher"
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg
@click="toggleSidebar"
/>
</div>
</ButtonGroup>
</div>
<template v-else />
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
defineProps({
isMobileSidebarOpen: {
@@ -45,14 +46,17 @@ const toggleSidebar = () => {
},
]"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<ButtonGroup
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button
icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
no-animation
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg
@click="toggleSidebar"
/>
</div>
</ButtonGroup>
</div>
<template v-else />
</template>
@@ -1,5 +1,7 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
initialActiveTab: {
type: Number,
@@ -22,6 +24,32 @@ const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab);
const tabRefs = ref([]);
const indicatorStyle = ref({});
const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
});
const selectTab = index => {
emit('tabChanged', props.tabs[index]);
};
@@ -37,20 +65,30 @@ const showDivider = index => {
</script>
<template>
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
:style="indicatorStyle"
/>
<template v-for="(tab, index) in tabs" :key="index">
<button
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
:ref="el => (tabRefs[index] = el)"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
: 'text-n-slate-10 outline-transparent h-8',
? 'text-n-blue-text scale-100'
: 'text-n-slate-10 scale-[0.98]',
]"
@click="selectTab(index)"
>
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button>
<div
v-if="index < tabs.length - 1"
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class="
showDivider(index)
@@ -15,6 +15,7 @@ import {
CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore();
@@ -133,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
@@ -142,6 +143,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
size="sm"
color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading"
@click="onCmdResolveConversation"
@@ -151,6 +153,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
size="sm"
color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading"
@click="onCmdOpenConversation"
@@ -160,6 +163,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
size="sm"
color="slate"
no-animation
:is-loading="isLoading"
@click="onCmdOpenConversation"
/>
@@ -169,12 +173,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="i-lucide-chevron-down"
:disabled="isLoading"
size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
color="slate"
trailing-icon
@click="openDropdown"
/>
</div>
</ButtonGroup>
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
}
function handleEmptyBodyWithSignature() {
const { schema, tr } = state;
const { schema, tr, doc } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and
// start a transaction to append it at the end
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:class="{
'cursor-not-allowed': disabled,
@@ -1,22 +0,0 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
rescue StandardError => e
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
raise e if Rails.env.test?
end
private
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
end
@@ -1,19 +0,0 @@
class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_in_batches do |accounts|
accounts.each do |account|
next unless account.feature_enabled?('assignment_v2')
account.inboxes.joins(:assignment_policy).find_in_batches do |inboxes|
inboxes.each do |inbox|
next unless inbox.auto_assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
end
end
end
end
end
end
@@ -14,13 +14,7 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
else
# Use legacy assignment system
AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
def should_run_auto_assignment?
@@ -1,28 +0,0 @@
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def member_ids_with_assignment_capacity
member_ids
end
private
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
end
InboxAgentAvailability.prepend_mod_with('InboxAgentAvailability')
+6 -3
View File
@@ -228,14 +228,17 @@ class Conversation < ApplicationRecord
def determine_conversation_status
self.status = :resolved and return if contact.blocked?
# Message template hooks aren't executed for conversations from campaigns
# So making these conversations open for agent visibility
return if campaign.present?
return handle_campaign_status if campaign.present?
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
self.status = :pending if inbox.active_bot?
end
def handle_campaign_status
# If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
end
def notify_conversation_creation
dispatcher_dispatch(CONVERSATION_CREATED)
end
-5
View File
@@ -44,7 +44,6 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include InboxAgentAvailability
# Not allowing characters:
validates :name, presence: true
@@ -191,10 +190,6 @@ class Inbox < ApplicationRecord
members.ids
end
def auto_assignment_v2_enabled?
account.feature_enabled?('assignment_v2') && assignment_policy.present? && assignment_policy.enabled?
end
private
def default_name_for_blank_name
+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,90 +0,0 @@
class AutoAssignment::AssignmentService
pattr_initialize [:inbox!]
def perform_bulk_assignment(limit: 100)
return 0 unless inbox.auto_assignment_v2_enabled?
assigned_count = 0
unassigned_conversations(limit).each do |conversation|
assigned_count += 1 if perform_for_conversation(conversation)
end
assigned_count
end
private
def perform_for_conversation(conversation)
return false unless assignable?(conversation)
agent = find_available_agent
return false unless agent
assign_conversation(conversation, agent)
end
def assignable?(conversation)
conversation.status == 'open' &&
conversation.assignee_id.nil?
end
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
scope = if assignment_config['conversation_priority'].to_s == 'longest_waiting'
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
end
scope.limit(limit)
end
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
return nil if agents.empty?
round_robin_selector.select_agent(agents)
end
def filter_agents_by_rate_limit(agents)
agents.select do |agent_member|
rate_limiter = build_rate_limiter(agent_member.user)
rate_limiter.within_limit?
end
end
def assign_conversation(conversation, agent)
conversation.update!(assignee: agent)
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
end
def dispatch_assignment_event(conversation, agent)
Rails.configuration.dispatcher.dispatch(
Events::Types::ASSIGNEE_CHANGED,
Time.zone.now,
conversation: conversation,
user: agent
)
end
def build_rate_limiter(agent)
AutoAssignment::RateLimiter.new(inbox: inbox, agent: agent)
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def assignment_config
@assignment_config ||= inbox.auto_assignment_config || {}
end
end
AutoAssignment::AssignmentService.prepend_mod_with('AutoAssignment::AssignmentService')
@@ -1,49 +0,0 @@
class AutoAssignment::RateLimiter
pattr_initialize [:inbox!, :agent!]
def within_limit?
return true unless enabled?
current_count < limit
end
def track_assignment(conversation)
return unless enabled?
assignment_key = build_assignment_key(conversation.id)
Redis::Alfred.set(assignment_key, conversation.id.to_s, ex: window)
end
def current_count
return 0 unless enabled?
pattern = assignment_key_pattern
Redis::Alfred.keys_count(pattern)
end
private
def enabled?
limit.present? && limit.positive?
end
def limit
config['fair_distribution_limit']&.to_i
end
def window
config['fair_distribution_window']&.to_i || 3600
end
def config
@config ||= inbox.auto_assignment_config || {}
end
def assignment_key_pattern
format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
end
def build_assignment_key(conversation_id)
format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation_id)
end
end
@@ -1,16 +0,0 @@
class AutoAssignment::RoundRobinSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_user_ids = available_agents.map(&:user_id).map(&:to_s)
round_robin_service.available_agent(allowed_agent_ids: agent_user_ids)
end
private
def round_robin_service
@round_robin_service ||= AutoAssignment::InboxRoundRobinService.new(inbox: inbox)
end
end
@@ -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?
-747
View File
@@ -1,747 +0,0 @@
# Assignment v2 Feature Documentation
## Overview
Assignment v2 is an intelligent, automated conversation distribution system that ensures conversations are fairly and efficiently assigned to available agents. It replaces the legacy assignment mechanism with a more sophisticated approach that considers multiple factors like agent capacity, availability, and conversation priority.
## Purpose
Instead of conversations sitting unassigned or being manually assigned one-by-one, Assignment v2 automatically distributes them across your team based on configurable rules and policies. This ensures:
- **No conversations are left unassigned** — Open conversations get assigned to agents automatically
- **Fair workload distribution** — Conversations are spread evenly across the team
- **Respects agent capacity** — Agents don't get overloaded with too many conversations
- **Flexible prioritization** — You control how conversations are prioritized (oldest first vs. longest waiting)
---
## How It Works: The Auto-Assignment Flow
Assignment v2 operates through two parallel mechanisms:
### 1. Real-time Assignment Trigger (AutoAssignmentHandler)
The system monitors conversation changes in real-time through the `AutoAssignmentHandler` concern:
- **Triggers on**: Every conversation save operation
- **Assignment occurs when**:
- A conversation transitions to `status: 'open'` with no assignee
- A conversation has an assignee who is no longer an inbox member
- **Action**: Immediately enqueues an `AutoAssignment::AssignmentJob` for that inbox
- **Fallback**: If assignment_v2 is not enabled, falls back to legacy `AgentAssignmentService`
This ensures conversations get assigned immediately when they become eligible, without waiting for the periodic job.
### 2. Periodic Assignment Cycle
The system also runs a **30-minute cycle** through an automated job (`AutoAssignment::PeriodicAssignmentJob`) configured in `config/schedule.yml`:
1. **Discovery Phase**: The system checks all accounts in batches
- Only accounts with the `assignment_v2` feature flag enabled are processed
- Uses `find_in_batches` for memory efficiency with large datasets
2. **Queue Phase**: For each inbox in those accounts:
- Only inboxes with an `assignment_policy` linked are considered
- The inbox must have `enable_auto_assignment` set to true
- The linked assignment policy must be enabled
- An `AutoAssignment::AssignmentJob` is queued for each eligible inbox
3. **Processing Phase**: Each assignment job:
- Fetches up to 100 unassigned, open conversations (configurable via `AUTO_ASSIGNMENT_BULK_LIMIT` env var)
- Processes conversations one at a time in the order determined by the priority policy
4. **Assignment Phase**: For each conversation, the system:
- Checks if the conversation is assignable (open status, no current assignee)
- Finds agents who are available (online status) and have capacity
- Filters agents by rate limiting rules
- Selects one agent using the configured selection strategy
- Assigns the conversation to that agent
- Tracks the assignment in Redis for rate limiting
- Dispatches an `ASSIGNEE_CHANGED` event
### Key Architectural Details
- **Job Queues**:
- `PeriodicAssignmentJob` runs in the `scheduled_jobs` queue
- `AssignmentJob` runs in the `default` queue
- **Batched Processing**: Uses `find_in_batches` for accounts and inboxes to handle large-scale deployments efficiently
- **Error Handling**:
- Errors are logged with the inbox ID
- In test environments, errors are re-raised for debugging
- In production, errors don't crash the entire job cycle
- **Conversation Limits**: Default is 100 conversations per job run, preventing memory issues and long-running jobs
---
## Prerequisites & Feature Enablement
Assignment v2 requires **all** of the following conditions to be met:
1. **Account-Level Feature Flag**: The account must have the `assignment_v2` feature enabled (configured in `config/features.yml`)
2. **Assignment Policy**: An `AssignmentPolicy` must exist and be linked to the inbox via `InboxAssignmentPolicy`
3. **Policy Enabled**: The `AssignmentPolicy` must have `enabled: true` (acts as a soft switch)
4. **Inbox Auto-Assignment**: The inbox must have `enable_auto_assignment: true`
If any of these conditions is not met, the inbox is skipped entirely and no assignments occur. The system checks these conditions via the `Inbox#auto_assignment_v2_enabled?` method.
---
## Assignment Policies
Policies determine HOW conversations are selected and assigned. Here are the main policies:
### 1. Conversation Priority Policy
**What it does**: Controls which conversations get assigned first
**Options**:
- **Longest Waiting Mode** (`longest_waiting`):
- Prioritizes conversations based on `last_activity_at` (oldest first)
- Uses `last_activity_at ASC, created_at ASC` ordering
- Ensures customers waiting longest for a response get priority
- Ideal for support teams focused on response time SLAs
- **Default Mode** (or any other value):
- Conversations are assigned in the order they were created
- Uses `created_at ASC` ordering
- First In, First Out (FIFO) approach
- Ideal for teams that want to clear backlogs chronologically
**Example**:
```
Conversation A: Created 2 hours ago, last activity 2 hours ago
Conversation B: Created 1 hour ago, last activity 30 minutes ago
Conversation C: Created 30 minutes ago, last activity 30 minutes ago
Longest Waiting: A → B → C (prioritizes least recent activity)
Default: A → B → C (prioritizes creation order)
```
### 2. Fair Distribution / Rate Limiting Policy
**What it does**: Prevents any single agent from being overwhelmed by too many assignments in a short time window
**Configuration**:
- `fair_distribution_limit`: Maximum number of assignments per agent within the time window (integer)
- `fair_distribution_window`: Time window in seconds (integer, defaults to 3600 seconds / 1 hour)
**How it works**:
- When enabled, the system tracks each assignment in Redis
- Redis key pattern: `chatwoot:assignment:{inbox_id}:{agent_id}:{conversation_id}`
- Each key has a TTL (Time To Live) equal to the configured window
- Before assigning, the system counts existing keys for that agent
- If count >= limit, the agent is filtered out from eligible agents
- After assignment, a new Redis key is created with the window TTL
**Behavior**:
- If `fair_distribution_limit` is not set or is 0, rate limiting is disabled
- Rate limiting is applied **per inbox per agent**
- Once the time window expires, old assignments are automatically removed from Redis
- Agents become eligible again once their count drops below the limit
**Example**:
```
Configuration: limit=5, window=3600 (1 hour)
Agent A is assigned 5 conversations between 10:00-10:15am
At 10:16am, Agent A is filtered out (at limit)
At 11:01am (after the first assignment expires), Agent A is eligible again
```
**Edge Cases**:
- If ALL agents hit their rate limit, no assignments occur (0 assignments returned)
- Rate limits are checked AFTER agent availability but BEFORE capacity checks
- Rate limiting tracks assignments, not current workload (different from capacity)
### 3. Agent Availability Policy
**What it does**: Ensures only agents who are actually available get new conversations
**How it works**:
- Uses `OnlineStatusTracker` to check real-time agent status
- Fetches all users with their status from Redis
- Only agents with status exactly equal to `'online'` are eligible
- Agents with status `'busy'`, `'offline'`, or any other value are filtered out
**Integration**:
- The system calls `inbox.available_agents` which:
1. Fetches online agent IDs from `OnlineStatusTracker`
2. Filters inbox members to only those with online status
3. Returns `InboxMember` records (not `User` records)
**Edge Cases**:
- If no agents are online for an inbox, 0 assignments occur
- Agent availability is checked at the time of assignment (not when the job starts)
- If an agent goes offline mid-job, they won't receive more assignments
**Important**: The system does NOT check:
- Agent's working hours
- Agent's timezone
- Agent's custom availability settings (these would need to be handled separately)
### 4. Capacity Management Policy (Enterprise Feature)
**What it does**: Advanced workload balancing based on agent's current load and conversation history
**How it works**:
- Each agent can be assigned to an `AgentCapacityPolicy`
- The policy defines `InboxCapacityLimit` records (one per inbox)
- Each limit specifies a `conversation_limit` (integer)
- The system counts the agent's current **open** conversations in that specific inbox
- If `current_count >= conversation_limit`, the agent is filtered out
**Key Characteristics**:
- **Inbox-specific**: Limits apply per inbox, so an agent can have different limits for different inboxes
- **Open conversations only**: Only counts conversations with `status: 'open'`
- **Optional**: If an agent has no `AgentCapacityPolicy`, they have unlimited capacity
- **Per-inbox limits**: If a capacity policy exists but has no `InboxCapacityLimit` for a specific inbox, the agent has unlimited capacity for that inbox
**Example**:
```
Agent A has a capacity policy with:
- Inbox 1 (Support): limit = 10
- Inbox 2 (Sales): limit = 20
Agent A currently has:
- 10 open conversations in Inbox 1
- 5 open conversations in Inbox 2
Result:
- Agent A is at capacity for Inbox 1 (10 >= 10) → filtered out
- Agent A has capacity for Inbox 2 (5 < 20) → eligible
```
**Edge Cases**:
- Resolved/closed conversations do NOT count toward capacity
- Capacity is checked at assignment time, not continuously
- If an agent goes over capacity manually (outside auto-assignment), they're still filtered out
### 5. Exclusion Rules (Enterprise Feature)
**What it does**: Allows excluding certain conversations from auto-assignment based on labels or age
**Configuration** (on `AgentCapacityPolicy`):
```ruby
exclusion_rules = {
'excluded_labels' => ['VIP', 'Escalation', 'Manual'],
'exclude_older_than_hours' => 24
}
```
**Behavior**:
**Label-Based Exclusions**:
- Conversations tagged with any of the excluded labels are skipped
- Uses the `tagged_with` method with `exclude: true`
- Multiple labels are treated as OR (conversation with ANY excluded label is skipped)
- Label matching is exact (case-sensitive)
**Age-Based Exclusions**:
- Conversations older than the specified hours are skipped
- Age is calculated from `created_at`, not `last_activity_at`
- Uses `where('conversations.created_at >= ?', hours.hours.ago)`
- If set to 24, conversations created more than 24 hours ago are excluded
**Combined Exclusions**:
- Both rules are applied together (AND logic)
- A conversation must pass both checks to be eligible
- Example: Must be less than 24 hours old AND not have an excluded label
**Edge Cases**:
- If `exclusion_rules` is nil or empty, no exclusions apply
- If there's no capacity policy linked to the inbox, exclusion rules don't apply
- Exclusions are applied BEFORE agent selection (reduces conversations to assign)
---
## Selection Strategies
Once eligible agents are identified (after availability, rate limiting, and capacity checks), the system uses a selection strategy to pick the agent:
### Round-Robin (Default)
**Implementation**: Uses `AutoAssignment::RoundRobinSelector` which delegates to `AutoAssignment::InboxRoundRobinService`
**How it works**:
- Maintains a Redis-based queue of agent IDs for the inbox
- Each time an agent is selected, they're moved to the back of the queue
- The next agent in the queue is always selected
- Ensures even distribution over time
**Characteristics**:
- **Fair over time**: Each agent gets an equal turn
- **Stateful**: Uses Redis to maintain queue state across jobs
- **Inbox-specific**: Each inbox has its own round-robin queue
**Example**:
```
Queue: [Agent A, Agent B, Agent C]
Assignment 1: Agent A (queue becomes [Agent B, Agent C, Agent A])
Assignment 2: Agent B (queue becomes [Agent C, Agent A, Agent B])
Assignment 3: Agent C (queue becomes [Agent A, Agent B, Agent C])
```
**Edge Cases**:
- If an agent is removed from the inbox, they're removed from the queue
- If a new agent is added, they're added to the queue
- The queue is validated and reset if it becomes inconsistent (membership drift)
- The `InboxRoundRobinService` validates the queue and resets it when agent membership changes
### Balanced / Workload-Based (Enterprise)
**Implementation**: Uses `Enterprise::AutoAssignment::BalancedSelector`
**How it works**:
- For each eligible agent, counts their current **open** conversations in the inbox
- Selects the agent with the **minimum** count
- Uses `min_by` which returns the first agent if there's a tie
**Characteristics**:
- **Real-time balancing**: Based on current workload, not historical assignments
- **Inbox-specific**: Only counts conversations in the specific inbox
- **Open conversations only**: Ignores resolved/closed conversations
- **Prioritizes new agents**: Agents with 0 conversations are always selected first
**Example**:
```
Agent A: 5 open conversations
Agent B: 3 open conversations
Agent C: 8 open conversations
Selection: Agent B (has the least workload)
After assignment:
Agent A: 5, Agent B: 4, Agent C: 8
Next selection: Agent B again (still has least)
```
**Comparison**:
| Aspect | Round-Robin | Balanced |
|--------|-------------|----------|
| **Goal** | Equal distribution over time | Equal workload at any moment |
| **State** | Redis queue | Database query |
| **Speed** | Very fast (Redis lookup) | Slower (DB count query) |
| **Accuracy** | Equal turns, not equal workload | Equal workload |
| **Best for** | High-volume, fast assignments | Teams where agents have varying resolve rates |
**Configuration**:
- In OSS: Always uses Round-Robin
- In Enterprise: Can set `balanced: true` on the assignment policy to use Balanced strategy
---
## Important Behaviors & Edge Cases
### Conversation Eligibility
A conversation is only eligible for auto-assignment if ALL of the following are true:
1. **Status is 'open'**: Resolved, pending, or snoozed conversations are never assigned
2. **No current assignee**: Conversations with an existing assignee are never reassigned
3. **Passes exclusion rules** (if Enterprise): Must not have excluded labels and must be within age threshold
4. **In an eligible inbox**: The inbox must have auto-assignment v2 enabled
**Edge Cases**:
- If a conversation is manually assigned mid-job, it's skipped (already has assignee)
- If a conversation is resolved mid-job, it's skipped (no longer open)
- Assignment v2 NEVER reassigns conversations (even if the current agent goes offline)
### Agent Eligibility
An agent is only eligible for assignment if ALL of the following are true:
1. **Member of the inbox**: Agent must be an `InboxMember`
2. **Status is 'online'**: Checked via `OnlineStatusTracker` at assignment time
3. **Within rate limit** (if configured): Agent hasn't exceeded assignments in the current window
4. **Has capacity** (if Enterprise): Agent's open conversation count is below their limit for this inbox
**Edge Cases**:
- If an agent meets all criteria but goes offline between the eligibility check and assignment, they may still receive the conversation (race condition)
- If ALL agents are filtered out, the conversation remains unassigned until the next cycle
### Zero-Assignment Scenarios
The system may assign 0 conversations if:
1. No unassigned, open conversations exist in the inbox
2. All conversations are excluded by exclusion rules
3. No agents are online
4. All online agents are at their rate limit
5. All online agents are at capacity (Enterprise)
6. The assignment policy is disabled mid-job
### Assignment Events
When a conversation is successfully assigned, the system:
1. Updates the conversation's `assignee_id` in the database
2. Creates a Redis key to track the assignment (for rate limiting)
3. Dispatches an `Events::Types::ASSIGNEE_CHANGED` event with:
- `conversation`: The conversation object
- `user`: The assigned agent
- Timestamp of the assignment
**Event Integration**:
- Webhooks subscribed to `ASSIGNEE_CHANGED` will be triggered
- The conversation model also dispatches a `conversation.updated` event
- These events can be used for notifications, integrations, or analytics
### Error Handling
**Job-Level Errors**:
- If an `AssignmentJob` encounters an error, it logs the error with the inbox ID
- In production, the error is caught and logged, allowing other jobs to continue
- In test environment, errors are re-raised for debugging
**Service-Level Errors**:
- If a single conversation assignment fails, it's skipped and the job continues
- The `assigned_count` only includes successful assignments
- Database errors (like constraint violations) are logged but don't stop the job
**Graceful Degradation**:
- If Redis is unavailable, rate limiting is skipped (all assignments proceed)
- If OnlineStatusTracker fails, no agents are considered online (0 assignments)
- If the inbox is deleted mid-job, the job exits early
### Performance Considerations
**Batch Processing**:
- Accounts are processed in batches to avoid loading all accounts into memory
- Inboxes are processed in batches to avoid loading all inboxes into memory
- Conversations are limited to 100 per job to prevent long-running jobs
**Redis Usage**:
- Rate limiting creates one Redis key per assignment
- Keys automatically expire based on the configured window
- Pattern matching (`keys_count`) is used to count assignments (can be expensive with many agents)
**Database Queries**:
- Uses `includes(:user)` in `InboxAgentAvailability#available_agents` to preload users and avoid N+1 queries
- Falls back to `inbox_members.none` when no agents are online to avoid unnecessary database hits
- Uses `group(:assignee_id).count` for balanced selector (efficient aggregation)
- Uses `limit(100)` to prevent loading thousands of conversations at once
**Scaling Recommendations**:
- For high-volume inboxes, increase `AUTO_ASSIGNMENT_BULK_LIMIT` (e.g., 200-500)
- Consider shorter rate limiting windows for faster cycling (e.g., 1800 seconds instead of 3600)
- Use balanced selector for teams where workload balance is critical
---
## API Endpoints
Assignment v2 provides REST API endpoints for managing assignment policies:
### Policy Management
- **CRUD Operations**: `Api::V1::Accounts::AssignmentPoliciesController`
- `GET /api/v1/accounts/:account_id/assignment_policies` - List all policies
- `POST /api/v1/accounts/:account_id/assignment_policies` - Create a policy
- `PUT /api/v1/accounts/:account_id/assignment_policies/:id` - Update a policy
- `DELETE /api/v1/accounts/:account_id/assignment_policies/:id` - Delete a policy
- **Accepts**: `assignment_order`, `conversation_priority`, `fair_distribution_limit`, `fair_distribution_window`, `enabled`
### Inbox-Policy Linking
- **Link/Unlink**: `Api::V1::Accounts::Inboxes::AssignmentPoliciesController`
- `POST /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy` - Attach policy to inbox
- `DELETE /api/v1/accounts/:account_id/inboxes/:inbox_id/assignment_policy` - Detach policy from inbox
- **Note**: Only one policy can be linked to an inbox at a time
### Policy-Inbox Queries
- **List Linked Inboxes**: `Api::V1::Accounts::AssignmentPolicies::InboxesController`
- `GET /api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes` - List all inboxes using a policy
### Authorization
- All endpoints require proper account scoping
- Requires `AssignmentPolicy` authorization for the requesting user
---
## Configuration & Flexibility
Assignment v2 is designed to be flexible at multiple levels:
### Account Level
- **Feature Flag**: `assignment_v2` must be enabled on the account
- **Enterprise Features**: Capacity and exclusion rules require Enterprise
### Policy Level
- **Assignment Order**: `round_robin` (default) or `balanced` (Enterprise only)
- **Conversation Priority**: `longest_waiting` or `earliest_created` (default/FIFO)
- **Fair Distribution Limit**: Integer (e.g., 5)
- **Fair Distribution Window**: Integer in seconds (defaults to 3600 when omitted)
- **Enabled**: Boolean soft switch to enable/disable the policy
### Inbox Level
- **Auto-Assignment**: `enable_auto_assignment` boolean
- **Policy Link**: `InboxAssignmentPolicy` links inbox to a policy (one-to-one relationship)
- **Capacity Limits** (Enterprise): Per-inbox conversation limits
- **Configuration Overrides**:
- **OSS**: The `inbox.auto_assignment_config` JSONB field can override policy settings for per-inbox customization
- **Enterprise**: Policy settings take precedence over `inbox.auto_assignment_config` - edit the policy directly
### Agent Level
- **Capacity Policy** (Enterprise): Optional `AgentCapacityPolicy` per agent
- **Availability**: Real-time online status tracked by `OnlineStatusTracker`
### Environment Level
- **Bulk Limit**: `AUTO_ASSIGNMENT_BULK_LIMIT` env var (default: 100)
- **Job Schedule**: Configurable via job scheduler (default: 30 minutes)
---
## System Architecture
### Components
1. **Jobs**:
- `AutoAssignment::PeriodicAssignmentJob`: Scheduled job that discovers eligible inboxes
- `AutoAssignment::AssignmentJob`: Per-inbox job that performs assignments
2. **Services**:
- `AutoAssignment::AssignmentService`: Core assignment logic (OSS)
- `Enterprise::AutoAssignment::AssignmentService`: Extended with capacity and exclusion rules
- `AutoAssignment::RateLimiter`: Rate limiting logic using Redis
- `Enterprise::AutoAssignment::CapacityService`: Capacity checking logic
3. **Selectors**:
- `AutoAssignment::RoundRobinSelector`: Round-robin selection strategy
- `Enterprise::AutoAssignment::BalancedSelector`: Workload-based selection strategy
4. **Models**:
- `AssignmentPolicy`: Configures assignment behavior
- `InboxAssignmentPolicy`: Links inbox to policy
- `AgentCapacityPolicy` (Enterprise): Defines capacity rules
- `InboxCapacityLimit` (Enterprise): Per-inbox limits
5. **Concerns**:
- `InboxAgentAvailability`: Provides `available_agents` method for inboxes
### Data Flow
```
1. PeriodicAssignmentJob (every 30 min)
2. For each Account with assignment_v2
3. For each Inbox with auto_assignment_v2_enabled?
4. Queue AssignmentJob(inbox_id)
5. AssignmentService.perform_bulk_assignment(limit: 100)
6. Fetch unassigned conversations (with priority ordering)
7. For each conversation:
a. Check assignable? (open + no assignee)
b. Find available agents (online + rate limit + capacity)
c. Select agent (round-robin or balanced)
d. Assign conversation
e. Track in Redis
f. Dispatch event
8. Return assigned_count
```
### Redis Keys
**Rate Limiting**:
- Pattern: `chatwoot:assignment:{inbox_id}:{agent_id}:*`
- Key: `chatwoot:assignment:{inbox_id}:{agent_id}:{conversation_id}`
- TTL: Equal to `fair_distribution_window` (default 3600 seconds)
**Round-Robin Queue**:
- Key: `chatwoot:round_robin:{inbox_id}`
- Type: List (LPUSH/RPOP operations)
- Persistent (no TTL)
---
## Advantages Over Manual Assignment
| Aspect | Manual | Assignment v2 |
|--------|--------|---|
| **Speed** | Slow (requires human action) | Instant (automatic every 30 min) |
| **Consistency** | Variable (depends on person) | Consistent (follows rules) |
| **Fairness** | Prone to bias | Fair and data-driven |
| **Scalability** | Doesn't scale (1 person = bottleneck) | Scales infinitely |
| **Workload Balance** | Hard to maintain | Automatically balanced |
| **Priority** | Manual judgment | Configurable policies |
| **24/7 Operation** | Requires shifts | Automated |
| **Onboarding** | New agents may be overlooked | New agents automatically included |
| **Audit Trail** | Limited | Full event log |
---
## Integration Points
Assignment v2 integrates with several Chatwoot systems:
1. **Conversation Model**:
- Reads: `status`, `assignee_id`, `last_activity_at`, `created_at`
- Writes: `assignee_id`
- Scopes: `unassigned`, `open`
2. **OnlineStatusTracker**:
- Reads: Real-time agent online status from Redis
- Returns: Hash of `{user_id => status}`
3. **Redis**:
- Rate limiting: Stores assignment keys with TTL
- Round-robin: Maintains agent queues
- Performance: All Redis operations are non-blocking
4. **Events System**:
- Dispatches: `Events::Types::ASSIGNEE_CHANGED`
- Consumers: Webhooks, notifications, analytics
5. **Assignment Policy**:
- Reads: Priority, limits, windows, balanced mode
- Links: Via `InboxAssignmentPolicy`
6. **Capacity Policy** (Enterprise):
- Reads: Per-inbox limits, exclusion rules
- Links: Via `AccountUser` and `InboxCapacityLimit`
7. **Labels** (Enterprise):
- Reads: Conversation labels for exclusion rules
- Uses: ActsAsTaggableOn gem for label filtering
---
## For Non-Technical Stakeholders
Think of Assignment v2 as an intelligent dispatcher at a taxi company:
### Without Assignment v2
- Customers (conversations) call in and wait on hold
- Someone manually decides which driver (agent) should take each ride
- Some drivers get overloaded while others sit idle
- Customers who called first might not get picked up first
- The dispatcher becomes a bottleneck
### With Assignment v2
- The system automatically dispatches rides to available drivers
- Drivers who are online and not at capacity get new rides
- The system balances workload so no driver gets too many rides at once
- You can set rules: "Prioritize customers waiting longest" or "No driver gets more than 5 rides per hour"
- Drivers with VIP rides (excluded labels) can be handled separately
- Everything happens automatically every 30 minutes
### Benefits
- **Faster**: Customers get assigned immediately
- **Fairer**: All drivers get equal opportunities
- **Scalable**: Works with 5 drivers or 500
- **Configurable**: Rules can be adjusted based on business needs
- **Reliable**: Runs 24/7 without human intervention
---
## Troubleshooting
### "Conversations aren't being assigned"
Check the following in order:
1. **Feature flag**: Is `assignment_v2` enabled for the account?
2. **Assignment policy**: Does the inbox have a linked, enabled assignment policy?
3. **Inbox setting**: Is `enable_auto_assignment` true for the inbox?
4. **Agent availability**: Are any agents online?
5. **Rate limiting**: Are all agents at their rate limit? Check Redis keys
6. **Capacity** (Enterprise): Are all agents at capacity? Check open conversation counts
7. **Exclusion rules** (Enterprise): Are all conversations being excluded by labels or age?
8. **Job running**: Is `PeriodicAssignmentJob` scheduled and running?
### "Assignments are uneven"
- If using round-robin: Check the Redis queue for the inbox
- If some agents never get assignments: Check their online status
- If using balanced selector: Check open conversation counts per agent
- If rate limiting is too strict: Increase the limit or window
### "Too many/too few assignments"
- Check `AUTO_ASSIGNMENT_BULK_LIMIT` (default 100)
- Check rate limiting configuration
- Check capacity limits (Enterprise)
- Check conversation priority (longest_waiting vs. FIFO)
### "Redis keys not expiring"
- Verify `fair_distribution_window` is set correctly
- Check Redis configuration for key eviction policies
- Manually inspect keys: `Redis::Alfred.keys('chatwoot:assignment:*')`
---
## Testing Guidelines
### Test Coverage Touchpoints
**Core Spec Files**:
- `spec/services/auto_assignment/assignment_service_spec.rb` - Verifies open-only assignment, respect for limits, conversation priority, fair distribution, and event dispatching
- `spec/services/auto_assignment/rate_limiter_spec.rb` - Covers Redis key semantics, limit/window handling, and edge cases when configuration is absent
- `spec/services/auto_assignment/round_robin_selector_spec.rb` - Ensures proper Redis-backed round robin queue and handles empty agent pools
- `spec/jobs/auto_assignment/*` - Covers both immediate job and periodic scheduler behavior
- `spec/enterprise/services/enterprise/auto_assignment/*` - Asserts balanced selection, capacity filtering, and exclusion rule behavior
### Key Test Scenarios
1. **Basic Assignment**:
- Create unassigned, open conversation
- Create online agent
- Run assignment service
- Verify conversation is assigned
2. **Rate Limiting**:
- Configure rate limit (e.g., 2 per hour)
- Assign 2 conversations to an agent
- Verify 3rd conversation goes to a different agent
3. **Capacity Management**:
- Set capacity limit (e.g., 5 conversations)
- Create 5 open conversations for an agent
- Verify agent is filtered out of eligibility
4. **Exclusion Rules**:
- Configure excluded labels
- Create conversation with excluded label
- Verify conversation is not assigned
5. **Priority**:
- Create multiple conversations with different `last_activity_at`
- Configure `longest_waiting` priority
- Verify oldest `last_activity_at` is assigned first
6. **Agent Availability**:
- Set agent status to 'busy' or 'offline'
- Verify agent is not eligible
- Set status to 'online'
- Verify agent is now eligible
7. **Zero Assignments**:
- Disable assignment policy
- Verify 0 assignments occur
- Set all agents offline
- Verify 0 assignments occur
---
## Next Steps / Additional Reading
- To enable Assignment v2 for your account, contact your Chatwoot administrator
- For Enterprise features, see the Enterprise-specific capacity policies documentation
- For webhook integrations, refer to the Assignment Events documentation
- For performance tuning, consult the Scaling Chatwoot guide
---
**Last Updated**: November 2024
**Feature Status**: Stable (v2 final)
**Related PR**: #12320
**Architecture**: Modular with Enterprise extensions via `prepend_mod_with`
+8
View File
@@ -431,6 +431,14 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
# V2 Billing endpoints
get :credit_grants
get :v2_pricing_plans
get :v2_topup_options
post :v2_topup
post :v2_subscribe
post :cancel_subscription
post :change_pricing_plan
end
end
end
-6
View File
@@ -53,9 +53,3 @@ bulk_auto_assignment_job:
cron: '*/15 * * * *'
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
# executed every 30 minutes for assignment_v2
periodic_assignment_job:
cron: '*/30 * * * *'
class: 'AutoAssignment::PeriodicAssignmentJob'
queue: scheduled_jobs
+8
View File
@@ -0,0 +1,8 @@
# Stripe V2 Billing Scheduled Jobs
# Add these to your config/sidekiq_cron.yml or config/schedule.yml
v2_credit_sync:
cron: "0 * * * *" # Every hour
class: "Enterprise::Billing::CreditSyncJob"
queue: low
description: "Sync V2 billing credits with Stripe"
@@ -0,0 +1,96 @@
module Enterprise::Api::V1::Accounts::Concerns::BillingV2
extend ActiveSupport::Concern
included do
before_action :validate_topup_amount, only: [:v2_topup]
end
def credit_grants
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
grants = service.fetch_credit_grants
render json: { credit_grants: grants }
end
def v2_pricing_plans
plans = Enterprise::Billing::V2::PlanCatalog.plans
render json: { pricing_plans: plans }
end
def v2_topup_options
options = Enterprise::Billing::V2::TopupCatalog.options
render json: { topup_options: options }
end
def v2_topup
service = Enterprise::Billing::V2::TopupService.new(account: @account)
result = service.create_topup(credits: params[:credits].to_i)
if result[:success]
render json: { success: true, message: result[:message] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def v2_subscribe
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
result = service.create_subscription_checkout(
pricing_plan_id: params[:pricing_plan_id],
quantity: subscription_quantity
)
if result[:success]
render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] }
else
render json: { error: result[:message] }, status: :unprocessable_entity
end
end
def cancel_subscription
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
result = service.cancel_subscription
if result[:success]
# 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 subscription_quantity
[params[:quantity].to_i, 1].max
end
def validate_topup_amount
return if params[:credits].to_i.positive?
render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity
end
end
@@ -1,5 +1,7 @@
class Enterprise::Api::V1::AccountsController < Api::BaseController
include BillingHelper
include Enterprise::Api::V1::Accounts::Concerns::BillingV2
before_action :fetch_account
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
@@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# Attempt to verify the signature. If successful, we'll handle the event
begin
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
# Determine which webhook secret to use based on event type
webhook_secret = determine_webhook_secret(payload)
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
# Check if this is a V2 billing event
if v2_billing_event?(event.type)
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
else
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
end
# If we fail to verify the signature, then something was wrong with the request
rescue JSON::ParserError, Stripe::SignatureVerificationError
# Invalid payload
@@ -18,4 +27,23 @@ class Enterprise::Webhooks::StripeController < ActionController::API
# We've successfully processed the event without blowing up
head :ok
end
private
def determine_webhook_secret(payload)
# Parse the payload to check event type without full verification
parsed_payload = JSON.parse(payload)
event_type = parsed_payload['type']
if v2_billing_event?(event_type)
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
else
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
end
end
def v2_billing_event?(event_type)
Rails.logger.debug { "V2 billing event: #{event_type}" }
event_type.start_with?('v2.')
end
end
@@ -0,0 +1,96 @@
class Enterprise::Billing::CreditSyncJob < ApplicationJob
queue_as :low
def perform(account = nil)
if account
sync_single_account(account)
else
sync_all_accounts
end
end
private
def sync_all_accounts
Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts'
accounts_with_stripe = Account.where(
"custom_attributes->>'stripe_customer_id' IS NOT NULL AND (custom_attributes->>'stripe_billing_version')::integer = 2"
)
synced_count = 0
failed_count = 0
accounts_with_stripe.find_each do |account|
result = sync_account_credits(account)
if result[:success]
synced_count += 1 if result[:credits_reported].to_i.positive?
else
failed_count += 1
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
end
Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}"
{ synced: synced_count, failed: failed_count }
end
def sync_single_account(account)
Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}"
result = sync_account_credits(account)
if result[:success]
Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}"
else
Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}"
end
result
end
def sync_account_credits(account)
consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i
last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i
credits_to_report = consumed_credits - last_synced_credits
if credits_to_report.positive?
handle_positive_credits(account, credits_to_report, consumed_credits)
elsif credits_to_report.negative?
handle_negative_credits(account, credits_to_report, consumed_credits)
else
{ success: true, message: 'Already in sync', credits_reported: 0 }
end
rescue StandardError => e
handle_sync_error(account, e)
end
def handle_positive_credits(account, credits_to_report, consumed_credits)
reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account)
result = reporter.report(credits_to_report)
return result unless result[:success]
update_last_synced_credits(account, consumed_credits)
Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})"
result.merge(credits_reported: credits_to_report)
end
def handle_negative_credits(account, credits_to_report, consumed_credits)
Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}"
update_last_synced_credits(account, consumed_credits)
{ success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 }
end
def handle_sync_error(account, error)
Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
{ success: false, message: error.message }
end
def update_last_synced_credits(account, credits)
account.with_lock do
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
current_attributes['stripe_last_synced_credits'] = credits
account.update!(custom_attributes: current_attributes)
end
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
@@ -6,6 +6,5 @@ module Enterprise::Concerns::Inbox
has_one :captain_assistant,
through: :captain_inbox,
class_name: 'Captain::Assistant'
has_many :inbox_capacity_limits, dependent: :destroy
end
end
@@ -1,31 +0,0 @@
module Enterprise::InboxAgentAvailability
extend ActiveSupport::Concern
def member_ids_with_assignment_capacity
return member_ids unless capacity_filtering_enabled?
# Get online agents with capacity
agents = available_agents
agents = filter_by_capacity(agents) if capacity_filtering_enabled?
agents.map(&:user_id)
end
private
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_filtering_enabled?
inbox_members_scope.select do |inbox_member|
capacity_service.agent_has_capacity?(inbox_member.user, self)
end
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def capacity_service
@capacity_service ||= Enterprise::AutoAssignment::CapacityService.new
end
end
@@ -1,94 +0,0 @@
module Enterprise::AutoAssignment::AssignmentService
private
# Override assignment config to use policy if available
def assignment_config
return super unless policy
{
'conversation_priority' => policy.conversation_priority,
'fair_distribution_limit' => policy.fair_distribution_limit,
'fair_distribution_window' => policy.fair_distribution_window,
'balanced' => policy.balanced?
}.compact
end
# Extend agent finding to add capacity checks
def find_available_agent
agents = filter_agents_by_rate_limit(inbox.available_agents)
agents = filter_agents_by_capacity(agents) if capacity_filtering_enabled?
return nil if agents.empty?
selector = policy&.balanced? ? balanced_selector : round_robin_selector
selector.select_agent(agents)
end
def filter_agents_by_capacity(agents)
return agents unless capacity_filtering_enabled?
capacity_service = Enterprise::AutoAssignment::CapacityService.new
agents.select { |agent_member| capacity_service.agent_has_capacity?(agent_member.user, inbox) }
end
def capacity_filtering_enabled?
account.feature_enabled?('assignment_v2') &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def round_robin_selector
@round_robin_selector ||= AutoAssignment::RoundRobinSelector.new(inbox: inbox)
end
def balanced_selector
@balanced_selector ||= Enterprise::AutoAssignment::BalancedSelector.new(inbox: inbox)
end
def policy
@policy ||= inbox.assignment_policy
end
def account
inbox.account
end
# Override to apply exclusion rules
def unassigned_conversations(limit)
scope = inbox.conversations.unassigned.open
# Apply exclusion rules from capacity policy or assignment policy
scope = apply_exclusion_rules(scope)
# Apply conversation priority using enum methods if policy exists
scope = if policy&.longest_waiting?
scope.reorder(last_activity_at: :asc, created_at: :asc)
else
scope.reorder(created_at: :asc)
end
scope.limit(limit)
end
def apply_exclusion_rules(scope)
capacity_policy = inbox.inbox_capacity_limits.first&.agent_capacity_policy
return scope unless capacity_policy
exclusion_rules = capacity_policy.exclusion_rules || {}
scope = apply_label_exclusions(scope, exclusion_rules['excluded_labels'])
apply_age_exclusions(scope, exclusion_rules['exclude_older_than_hours'])
end
def apply_label_exclusions(scope, excluded_labels)
return scope if excluded_labels.blank?
scope.tagged_with(excluded_labels, exclude: true, on: :labels)
end
def apply_age_exclusions(scope, hours_threshold)
return scope if hours_threshold.blank?
hours = hours_threshold.to_i
return scope unless hours.positive?
scope.where('conversations.created_at >= ?', hours.hours.ago)
end
end
@@ -1,26 +0,0 @@
class Enterprise::AutoAssignment::BalancedSelector
pattr_initialize [:inbox!]
def select_agent(available_agents)
return nil if available_agents.empty?
agent_users = available_agents.map(&:user)
assignment_counts = fetch_assignment_counts(agent_users)
agent_users.min_by { |user| assignment_counts[user.id] || 0 }
end
private
def fetch_assignment_counts(users)
user_ids = users.map(&:id)
counts = inbox.conversations
.open
.where(assignee_id: user_ids)
.group(:assignee_id)
.count
Hash.new(0).merge(counts)
end
end
@@ -1,25 +0,0 @@
class Enterprise::AutoAssignment::CapacityService
def agent_has_capacity?(user, inbox)
# Get the account_user for this specific account
account_user = user.account_users.find_by(account: inbox.account)
# If no account_user or no capacity policy, agent has unlimited capacity
return true unless account_user&.agent_capacity_policy
policy = account_user.agent_capacity_policy
# Check if there's a specific limit for this inbox
inbox_limit = policy.inbox_capacity_limits.find_by(inbox: inbox)
# If no specific limit for this inbox, agent has unlimited capacity for this inbox
return true unless inbox_limit
# Count current open conversations for this agent in this inbox
current_count = user.assigned_conversations
.where(inbox: inbox, status: :open)
.count
# Agent has capacity if current count is below the limit
current_count < inbox_limit.conversation_limit
end
end
@@ -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,90 @@
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)
# First disable all premium features to handle downgrades
disable_all_premium_features
# Then enable features based on the current plan
enable_plan_specific_features(plan_name)
end
def enable_plan_specific_features(plan_name)
return if plan_name.blank?
# Enable features based on plan hierarchy
case plan_name
when 'Startup', 'Startups'
# Startup plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
# Business plan gets Startup features + Business features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
when 'Enterprise'
# Enterprise plan gets all features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
end
end
def reset_captain_usage
account.reset_response_usage if account.respond_to?(:reset_response_usage)
end
private
def enable_account_manually_managed_features
# Get manually managed features from internal attributes using the service
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
# Enable each feature
account.enable_features(*features) if features.present?
end
end
@@ -0,0 +1,42 @@
module Enterprise::Billing::Concerns::PlanProvisioningHelper
extend ActiveSupport::Concern
private
def provision_new_plan(new_pricing_plan_id)
sync_plan_credits(new_pricing_plan_id)
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
return unless plan_definition
plan_name = extract_plan_name(plan_definition)
enable_plan_specific_features(plan_name) if plan_name.present?
end
def sync_plan_credits(pricing_plan_id)
plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id)
Enterprise::Billing::V2::CreditManagementService
.new(account: account)
.sync_monthly_response_credits(plan_credits.to_i)
end
def extract_plan_name(plan_definition)
plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) }
end
def update_account_plan(new_pricing_plan_id, quantity, next_billing_date)
attributes = {
'stripe_pricing_plan_id' => new_pricing_plan_id,
'pending_stripe_pricing_plan_id' => nil,
'pending_subscription_quantity' => nil,
'subscribed_quantity' => quantity,
'next_billing_date' => next_billing_date
}
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
update_custom_attributes(attributes)
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,65 @@
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)
object.respond_to?(key) ? object.public_send(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, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
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,49 @@ 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?
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
service.add_response_topup_credits(amount)
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_credit_amount(grant)
# First, try to get credits from metadata
metadata = extract_attribute(grant, :metadata)
if metadata
credits = extract_attribute(metadata, :credits)
return credits.to_i if credits.present? && credits.to_i.positive?
end
# Fallback: extract from amount object
amount_data = extract_attribute(grant, :amount)
return 0 unless amount_data
0
end
def extract_attribute(object, attribute)
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
end
def extract_amount_value(amount_data, unit_type)
unit = extract_attribute(amount_data, unit_type)
return 0 unless unit
value = extract_attribute(unit, :value)
value.to_i
end
end
@@ -0,0 +1,90 @@
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 unless monthly || topup
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?
current_limits = account.limits.present? ? account.limits.deep_dup : {}
updates.each do |key, value|
current_limits[key.to_s] = value
end
account.update!(limits: current_limits)
end
def update_custom_attributes(updates)
return if updates.blank?
current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {}
updates.each do |key, value|
current_attributes[key.to_s] = value
end
account.update!(custom_attributes: current_attributes)
end
def custom_attribute(key)
account.custom_attributes&.[](key.to_s)
end
def with_locked_account(&)
account.with_lock(&)
end
# 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,142 @@
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
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)
{ success: true, redirect_url: session.url }
end
private
def validate_params
raise StandardError, 'Customer ID required. Please create a Stripe customer first.' 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, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key
[
{
type: 'pricing_plan_subscription_item',
pricing_plan_subscription_item: {
pricing_plan: @pricing_plan_id,
component_configurations: {
lookup_key => {
type: 'license_fee_component',
license_fee_component: {
quantity: @quantity
}
}
}
}
}
]
end
def extract_license_lookup_key
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
end
def session_metadata
{
account_id: account.id,
pricing_plan_id: @pricing_plan_id,
quantity: @quantity,
billing_version: 'v2'
}
end
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
Rails.logger.error("Failed to fetch credit grants: #{e.message}")
[]
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,104 @@
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
# @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?
return {
success: false,
message: 'No default payment method found. Please add a default payment method before making a purchase.'
}
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
# 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,112 @@
module Enterprise::Billing::V2::PlanCatalog
DEFAULT_CURRENCY = 'usd'.freeze
CREDIT_UNIT = 'Credits'.freeze
PLAN_DEFINITIONS = [
{
key: :free,
display_name: 'Chatwoot Hacker',
base_fee: 0.0,
monthly_credits: 0,
config_key: 'STRIPE_HACKER_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2'
},
{
key: :startup,
display_name: 'Chatwoot Startup',
base_fee: 19.0,
monthly_credits: 300,
config_key: 'STRIPE_STARTUP_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
},
{
key: :business,
display_name: 'Chatwoot Business',
base_fee: 39.0,
monthly_credits: 500,
config_key: 'STRIPE_BUSINESS_PLAN_ID',
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
},
{
key: :enterprise,
display_name: 'Chatwoot Enterprise',
base_fee: 99.0,
monthly_credits: 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 monthly_credits_for(plan_id)
definition = definition_for(plan_id)
definition ? definition[:monthly_credits] : nil
end
def plan_id_for(definition)
InstallationConfig.find_by(name: definition[:config_key])&.value
end
def lookup_key_for_plan(plan_id)
# Returns the licensed_item_lookup_key for checkout sessions
definition = definition_for(plan_id)
definition&.dig(:licensed_item_lookup_key)
end
def build_plan(definition, plan_id)
{
id: plan_id,
display_name: definition[:display_name],
currency: DEFAULT_CURRENCY,
tax_behavior: 'exclusive',
components: build_components(definition)
}
end
def build_components(definition)
components = [service_action_component(definition), rate_card_component(definition)]
components << license_fee_component(definition) if definition[:base_fee]&.positive?
components
end
def service_action_component(definition)
{
type: 'service_action',
name: 'Monthly Credits',
credit_amount: definition[:monthly_credits],
credit_unit: CREDIT_UNIT
}
end
def rate_card_component(_definition)
{
type: 'rate_card',
name: 'Overage Rate',
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,116 @@
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')
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)
sync_plan_credits(pricing_plan_id)
# 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,35 @@
class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService
def report(credits_used, _feature = nil)
return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration?
event = Stripe::Billing::MeterEvent.create(
meter_event_params(credits_used),
stripe_api_options
)
{ success: true, event_id: event.identifier }
rescue Stripe::StripeError => e
{ success: false, message: e.message }
end
private
def valid_configuration?
stripe_customer_id.present?
end
def meter_event_params(credits_used)
{
event_name: 'chatwoot.usage',
payload: {
value: credits_used.to_s,
stripe_customer_id: stripe_customer_id
},
identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}"
}
end
def stripe_api_options
{ api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' }
end
end
@@ -0,0 +1,60 @@
class Enterprise::Billing::V2::WebhookHandlerService
include Enterprise::Billing::Concerns::StripeV2ClientHelper
def perform(event:)
@event = event
return { success: false, message: 'Event is required' } if @event.blank?
return { success: false, message: 'Account not found' } if account.blank?
case @event.type
when 'v2.billing.pricing_plan_subscription.servicing_activated'
Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}"
handle_subscription_servicing_activated(@event.related_object.id)
when 'v2.billing.cadence.billed'
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
refresh_account_subscription_details(@event.related_object.id)
else
{ success: true }
end
rescue StandardError => e
Rails.logger.error "Error processing V2 webhook: #{e.message}"
{ success: false, error: e.message }
end
private
def account
@account ||= begin
related_object = @event.related_object
subscription_id = related_object.id
customer_id = fetch_customer_id_from_subscription(subscription_id)
found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present?
Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank?
found_account
end
end
def fetch_customer_id_from_subscription(subscription_id)
subscription = 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
+4 -43
View File
@@ -35,25 +35,6 @@ module Redis::Alfred
$alfred.with { |conn| conn.exists?(key) }
end
# set expiry on a key in seconds
def expire(key, seconds)
$alfred.with { |conn| conn.expire(key, seconds) }
end
# scan keys matching a pattern
def scan_each(match: nil, count: 100, &)
$alfred.with do |conn|
conn.scan_each(match: match, count: count, &)
end
end
# count keys matching a pattern
def keys_count(pattern)
count = 0
scan_each(match: pattern) { count += 1 }
count
end
# list operations
def llen(key)
@@ -100,15 +81,8 @@ module Redis::Alfred
# sorted set operations
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
if value.nil? && score.is_a?(Array)
# New syntax: score is actually an array of [score, member] pairs
$alfred.with { |conn| conn.zadd(key, score) }
else
# Support old syntax for backward compatibility
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
end
def zadd(key, score, value)
$alfred.with { |conn| conn.zadd(key, score, value) }
end
# get score of a value for key
@@ -116,22 +90,9 @@ module Redis::Alfred
$alfred.with { |conn| conn.zscore(key, value) }
end
# count members in a sorted set with scores within the given range
def zcount(key, min_score, max_score)
$alfred.with { |conn| conn.zcount(key, min_score, max_score) }
end
# get the number of members in a sorted set
def zcard(key)
$alfred.with { |conn| conn.zcard(key) }
end
# get values by score
def zrangebyscore(key, range_start, range_end, with_scores: false, limit: nil)
options = {}
options[:with_scores] = with_scores if with_scores
options[:limit] = limit if limit
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end, **options) }
def zrangebyscore(key, range_start, range_end)
$alfred.with { |conn| conn.zrangebyscore(key, range_start, range_end) }
end
# remove values by score
-5
View File
@@ -42,9 +42,4 @@ module Redis::RedisKeys
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
## Auto Assignment Keys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
end
+1 -1
View File
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.1",
"@chatwoot/prosemirror-schema": "1.2.3",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
+5 -5
View File
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.2.1
version: 1.2.1
specifier: 1.2.3
version: 1.2.3
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -406,8 +406,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.2.1':
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
'@chatwoot/prosemirror-schema@1.2.3':
resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -4768,7 +4768,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.2.1':
'@chatwoot/prosemirror-schema@1.2.3':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -1,207 +0,0 @@
# frozen_string_literal: true
# Test: Agent Availability and Status
# Run with: bundle exec rails runner script/assignment_v2/test_agent_availability.rb
#
# Tests:
# - Only online agents receive assignments
# - Offline agents are excluded from assignment pool
# - When agents go offline mid-assignment, remaining conversations go to online agents
# - When agents come online, they become eligible for new assignments
#
# Key Implementation Details:
# - inbox.available_agents filters by online status via OnlineStatusTracker
# - OnlineStatusTracker.update_presence(account_id, 'User', user_id) marks agent as online
# - OnlineStatusTracker.remove_presence(account_id, 'User', user_id) marks agent as offline
# - Availability is checked for EACH conversation assignment (not cached for the batch)
# - This ensures real-time status changes affect assignment within a single job run
require_relative 'test_helpers'
class TestAgentAvailability
include AssignmentV2TestHelpers
def run
section('TEST: Agent Availability and Status')
account = get_test_account
inbox = nil
begin
# Setup test inbox and policy
inbox = create_test_inbox(account, name: 'Availability Test Inbox')
policy = create_test_policy(account, name: 'Availability Test Policy')
link_policy_to_inbox(inbox, policy)
# Test 1: Only online agents receive assignments
section('Test 1: Only Online Agents Get Assigned')
# Create 3 agents: 2 online, 1 offline
agent_online_1 = create_test_agent(account, inbox, name: 'Agent Online 1', online: true)
agent_online_2 = create_test_agent(account, inbox, name: 'Agent Online 2', online: true)
agent_offline = create_test_agent(account, inbox, name: 'Agent Offline', online: false)
info("Online agents: #{agent_online_1.name}, #{agent_online_2.name}")
info("Offline agent: #{agent_offline.name}")
# Create 6 conversations (should be split between 2 online agents only)
create_bulk_conversations(inbox, count: 6)
# Run assignment
assigned_count = run_assignment(inbox)
# Verify only online agents got assignments
log('Verifying only online agents assigned...', color: :blue)
# Should assign all 6 conversations (2 online agents available)
all_assigned = assert_equal(
assigned_count,
6,
'All 6 conversations assigned'
)
# Each online agent should have 3 conversations (round-robin between 2)
online_1_count = inbox.conversations.where(assignee: agent_online_1).count
online_2_count = inbox.conversations.where(assignee: agent_online_2).count
offline_count = inbox.conversations.where(assignee: agent_offline).count
distribution_ok = assert_equal(
online_1_count,
3,
"#{agent_online_1.name} has 3 conversations"
)
distribution_ok &= assert_equal(
online_2_count,
3,
"#{agent_online_2.name} has 3 conversations"
)
# Offline agent should have 0
offline_excluded = assert_equal(
offline_count,
0,
"#{agent_offline.name} has 0 conversations (offline)"
)
test1_ok = all_assigned && distribution_ok && offline_excluded
# Test 2: When agent goes offline, new assignments skip them
section('Test 2: Agent Goes Offline Mid-Test')
# Take agent_online_2 offline by setting status to 'offline'
OnlineStatusTracker.set_status(account.id, agent_online_2.id, 'offline')
info("Took #{agent_online_2.name} offline")
# Create 3 more conversations
create_bulk_conversations(inbox, count: 3)
# Run assignment again
assigned_count_2 = run_assignment(inbox)
# Verify only agent_online_1 gets the new assignments
log('Verifying offline agent excluded...', color: :blue)
# Should assign all 3 new conversations
all_assigned_2 = assert_equal(
assigned_count_2,
3,
'All 3 new conversations assigned'
)
# Agent_online_1 should now have 6 total (3 + 3 new)
online_1_new_count = inbox.conversations.where(assignee: agent_online_1).count
online_1_got_all = assert_equal(
online_1_new_count,
6,
"#{agent_online_1.name} has 6 conversations (got all new ones)"
)
# Agent_online_2 should still have 3 (no new assignments)
online_2_still_count = inbox.conversations.where(assignee: agent_online_2).count
online_2_unchanged = assert_equal(
online_2_still_count,
3,
"#{agent_online_2.name} still has 3 conversations (offline, no new ones)"
)
test2_ok = all_assigned_2 && online_1_got_all && online_2_unchanged
# Test 3: When agent comes online, they become eligible
section('Test 3: Agent Comes Online')
# Bring agent_online_2 back online by updating presence and setting status
OnlineStatusTracker.update_presence(account.id, 'User', agent_online_2.id)
OnlineStatusTracker.set_status(account.id, agent_online_2.id, 'online')
info("Brought #{agent_online_2.name} back online")
# Bring offline agent online too
OnlineStatusTracker.update_presence(account.id, 'User', agent_offline.id)
OnlineStatusTracker.set_status(account.id, agent_offline.id, 'online')
info("Brought #{agent_offline.name} online")
# Create 6 more conversations (should distribute across all 3 now)
create_bulk_conversations(inbox, count: 6)
# Run assignment
assigned_count_3 = run_assignment(inbox)
# Verify all 3 agents get assignments
log('Verifying all online agents get assignments...', color: :blue)
# Should assign all 6 conversations
all_assigned_3 = assert_equal(
assigned_count_3,
6,
'All 6 final conversations assigned'
)
# Each agent should get 2 more (round-robin across 3)
agent_online_1_final = inbox.conversations.where(assignee: agent_online_1).count
agent_online_2_final = inbox.conversations.where(assignee: agent_online_2).count
agent_offline_final = inbox.conversations.where(assignee: agent_offline).count
online_1_increased = assert_equal(
agent_online_1_final,
8, # 6 + 2
"#{agent_online_1.name} has 8 conversations (6 + 2 new)"
)
online_2_increased = assert_equal(
agent_online_2_final,
5, # 3 + 2
"#{agent_online_2.name} has 5 conversations (3 + 2 new)"
)
# Previously offline agent should now have assignments
previously_offline_assigned = assert_equal(
agent_offline_final,
2, # 0 + 2
"#{agent_offline.name} has 2 conversations (came online, got 2 new)"
)
test3_ok = all_assigned_3 && online_1_increased && online_2_increased && previously_offline_assigned
# Show final distribution
show_assignment_distribution(inbox, [agent_online_1, agent_online_2, agent_offline])
# Final result
if test1_ok && test2_ok && test3_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestAgentAvailability.new.run ? 0 : 1)
@@ -1,209 +0,0 @@
# frozen_string_literal: true
# Test: Balanced Selector Strategy (Enterprise)
# Run with: bundle exec rails runner script/assignment_v2/test_balanced_selector.rb
#
# Tests:
# - Balanced selector assigns to agent with least open conversations
# - When agents have equal workload, any can be chosen
# - Workload is recalculated for each assignment (not cached)
# - Balanced selector produces different distribution than round-robin
#
# Key Implementation Details:
# - AssignmentPolicy has assignment_order enum: { round_robin: 0, balanced: 1 } (Enterprise)
# - Enterprise::AutoAssignment::AssignmentService uses balanced_selector when policy.balanced?
# - BalancedSelector.select_agent(agents) returns agent with min open conversation count
# - Counts are fetched fresh for each assignment: inbox.conversations.open.where(assignee_id: user_ids).count
# - This enables real-time workload balancing within a single assignment batch
require_relative 'test_helpers'
class TestBalancedSelector
include AssignmentV2TestHelpers
def run
skip_if_not_enterprise
section('TEST: Balanced Selector Strategy (Enterprise)')
account = get_test_account
inbox = nil
begin
# Setup test inbox with balanced policy
inbox = create_test_inbox(account, name: 'Balanced Selector Test Inbox')
# Create policy with balanced assignment order
policy = create_test_policy(
account,
name: 'Balanced Test Policy',
assignment_order: 'balanced' # Enterprise: balanced selector
)
link_policy_to_inbox(inbox, policy)
info('Created policy with assignment_order: balanced')
# Test 1: Initial balanced distribution
section('Test 1: Balanced Distribution from Zero')
# Create 3 agents
agent_1 = create_test_agent(account, inbox, name: 'Agent 1', online: true)
agent_2 = create_test_agent(account, inbox, name: 'Agent 2', online: true)
agent_3 = create_test_agent(account, inbox, name: 'Agent 3', online: true)
# Create 9 conversations (3 per agent if perfectly balanced)
create_bulk_conversations(inbox, count: 9)
# Run assignment
assigned_count = run_assignment(inbox)
# Verify balanced distribution
log('Verifying initial balanced distribution...', color: :blue)
# All 9 should be assigned
all_assigned = assert_equal(
assigned_count,
9,
'All 9 conversations assigned'
)
# Check distribution - should be 3 each (or as close as possible)
agent_1_count = inbox.conversations.where(assignee: agent_1).count
agent_2_count = inbox.conversations.where(assignee: agent_2).count
agent_3_count = inbox.conversations.where(assignee: agent_3).count
# All agents should have 3 conversations (perfect balance)
balanced_ok = assert(
agent_1_count == 3 && agent_2_count == 3 && agent_3_count == 3,
"Balanced distribution: #{agent_1.name}=3, #{agent_2.name}=3, #{agent_3.name}=3",
"Unbalanced: #{agent_1.name}=#{agent_1_count}, #{agent_2.name}=#{agent_2_count}, #{agent_3.name}=#{agent_3_count}"
)
test1_ok = all_assigned && balanced_ok
# Test 2: Rebalancing when agents have unequal workloads
section('Test 2: Rebalancing Unequal Workloads')
# Manually assign 5 more conversations to agent_1 (simulating prior workload)
manual_conversations = create_bulk_conversations(inbox, count: 5)
manual_conversations.each { |conv| conv.update!(assignee: agent_1) }
info("Manually assigned 5 conversations to #{agent_1.name}")
# Current state: agent_1 = 8, agent_2 = 3, agent_3 = 3
# Create 6 more conversations to assign
create_bulk_conversations(inbox, count: 6)
# Run assignment - balanced selector should favor agent_2 and agent_3
assigned_count_2 = run_assignment(inbox)
# Verify rebalancing behavior
log('Verifying rebalancing...', color: :blue)
# All 6 should be assigned
all_assigned_2 = assert_equal(
assigned_count_2,
6,
'All 6 new conversations assigned'
)
# Check new distribution
agent_1_new = inbox.conversations.where(assignee: agent_1, status: 'open').count
agent_2_new = inbox.conversations.where(assignee: agent_2, status: 'open').count
agent_3_new = inbox.conversations.where(assignee: agent_3, status: 'open').count
info("After rebalancing: #{agent_1.name}=#{agent_1_new}, #{agent_2.name}=#{agent_2_new}, #{agent_3.name}=#{agent_3_new}")
# agent_1 should still have 8 (no new assignments because already overloaded)
# agent_2 and agent_3 should have gotten the 6 new ones (3 each)
rebalanced_ok = assert(
agent_1_new == 8 && agent_2_new == 6 && agent_3_new == 6,
'Balanced selector avoided overloaded agent',
"Unexpected distribution: #{agent_1.name}=#{agent_1_new}, #{agent_2.name}=#{agent_2_new}, #{agent_3.name}=#{agent_3_new}"
)
test2_ok = all_assigned_2 && rebalanced_ok
# Test 3: Compare with round-robin behavior
section('Test 3: Balanced vs Round-Robin Comparison')
# Create a second inbox with round-robin policy for comparison
inbox_rr = create_test_inbox(account, name: 'Round-Robin Comparison Inbox')
policy_rr = create_test_policy(
account,
name: 'Round-Robin Policy',
assignment_order: 'round_robin'
)
link_policy_to_inbox(inbox_rr, policy_rr)
# Add same 3 agents to this inbox
inbox_rr.inbox_members.create!(user: agent_1)
inbox_rr.inbox_members.create!(user: agent_2)
inbox_rr.inbox_members.create!(user: agent_3)
# Give agent_1 a head start (5 conversations in round-robin inbox)
rr_manual = create_bulk_conversations(inbox_rr, count: 5)
rr_manual.each { |conv| conv.update!(assignee: agent_1) }
info("Round-robin inbox: Manually assigned 5 to #{agent_1.name}")
# Create 6 more conversations in round-robin inbox
create_bulk_conversations(inbox_rr, count: 6)
# Run assignment with round-robin
run_assignment(inbox_rr)
# Check round-robin distribution
agent_1_rr = inbox_rr.conversations.where(assignee: agent_1, status: 'open').count
agent_2_rr = inbox_rr.conversations.where(assignee: agent_2, status: 'open').count
agent_3_rr = inbox_rr.conversations.where(assignee: agent_3, status: 'open').count
info("Round-robin distribution: #{agent_1.name}=#{agent_1_rr}, #{agent_2.name}=#{agent_2_rr}, #{agent_3.name}=#{agent_3_rr}")
# Round-robin will assign 2 to each agent (6 ÷ 3 = 2 each), resulting in:
# agent_1 = 7, agent_2 = 2, agent_3 = 2 (unbalanced!)
rr_unbalanced = assert(
agent_1_rr > agent_2_rr && agent_1_rr > agent_3_rr,
'Round-robin creates unbalanced distribution when starting unequal',
'Round-robin distribution was balanced (unexpected)'
)
# Compare: balanced inbox should be more even
balanced_diff = (agent_1_new - agent_2_new).abs + (agent_2_new - agent_3_new).abs + (agent_1_new - agent_3_new).abs
rr_diff = (agent_1_rr - agent_2_rr).abs + (agent_2_rr - agent_3_rr).abs + (agent_1_rr - agent_3_rr).abs
better_balance = assert(
balanced_diff < rr_diff,
"Balanced selector (diff=#{balanced_diff}) is more even than round-robin (diff=#{rr_diff})",
"Balanced selector (diff=#{balanced_diff}) not better than round-robin (diff=#{rr_diff})"
)
test3_ok = rr_unbalanced && better_balance
# Show final comparison
log('Final Comparison:', color: :blue)
puts " Balanced Inbox: #{agent_1.name}=#{agent_1_new}, #{agent_2.name}=#{agent_2_new}, #{agent_3.name}=#{agent_3_new}"
puts " Round-Robin Inbox: #{agent_1.name}=#{agent_1_rr}, #{agent_2.name}=#{agent_2_rr}, #{agent_3.name}=#{agent_3_rr}"
# Cleanup round-robin inbox
cleanup_test_data(account, inbox: inbox_rr)
# Final result
if test1_ok && test2_ok && test3_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestBalancedSelector.new.run ? 0 : 1)
@@ -1,126 +0,0 @@
# frozen_string_literal: true
# Test: Basic Assignment Functionality
# Run with: bundle exec rails runner script/assignment_v2/test_basic_assignment.rb
#
# Tests:
# - Unassigned open conversations get assigned
# - Only online agents receive assignments
# - Round-robin distribution works correctly
# - Assignee is persisted in database
#
# Key Implementation Details:
# - Uses FactoryBot to create conversations with proper associations (contact, contact_inbox)
# - Requires inbox.reload after linking policy to pick up the association
# - Assignment job expects keyword argument: perform_now(inbox_id: id), not perform_now(id)
# - Round-robin is the default selection strategy when no policy specifies 'balanced'
require_relative 'test_helpers'
class TestBasicAssignment
include AssignmentV2TestHelpers
def run
section('TEST: Basic Assignment Functionality')
# Use Account ID 2 by default (configurable in test_helpers.rb)
account = get_test_account
inbox = nil
begin
# Setup test inbox and policy
# Note: Unique names are generated to avoid conflicts across test runs
inbox = create_test_inbox(account, name: 'Basic Test Inbox')
policy = create_test_policy(account, name: 'Basic Test Policy')
# Link policy to inbox
# Important: This creates InboxAssignmentPolicy and reloads inbox to pick up association
link_policy_to_inbox(inbox, policy)
# Create 3 online agents
# Note: Each agent gets a unique email and name to avoid validation errors
# Agents are automatically added to inbox members and marked online via OnlineStatusTracker
agents = 3.times.map do |i|
create_test_agent(account, inbox, name: "Agent #{i + 1}", online: true)
end
# Create 9 unassigned conversations (3 per agent for round-robin test)
# Uses FactoryBot to create conversations with all required associations:
# - Contact with account
# - ContactInbox with source_id (required field)
# - Conversation linked to inbox, account, contact, and contact_inbox
conversations = create_bulk_conversations(inbox, count: 9)
# Run assignment job
# Fix: Must use keyword argument inbox_id (not positional argument)
# The job calculates assigned_count internally, we derive it from before/after counts
assigned_count = run_assignment(inbox)
# Verify assignments
log('Verifying results...', color: :blue)
# Test 1: All conversations should be assigned
all_assigned = assert_equal(
inbox.conversations.unassigned.count,
0,
'All conversations assigned'
)
# Test 2: Assigned count should be 9
correct_count = assert_equal(
assigned_count,
9,
'Assigned count matches'
)
# Test 3: Each agent should have exactly 3 conversations (round-robin)
# Round-robin selector should distribute evenly: 9 conversations ÷ 3 agents = 3 each
distribution_correct = true
agents.each do |agent|
count = inbox.conversations.where(assignee: agent).count
if count == 3
success("#{agent.name} has 3 conversations")
else
error("#{agent.name} has #{count} conversations (expected 3)")
distribution_correct = false
end
end
# Test 4: Verify assignee is persisted in database
# Important: Must reload conversations to get updated assignee_id
persistence_ok = conversations.all? do |conv|
conv.reload
conv.assignee_id.present?
end
assert(
persistence_ok,
'All conversations have persisted assignee_id',
'Some conversations missing assignee_id'
)
# Show distribution summary
show_assignment_distribution(inbox, agents)
# Final result: All tests must pass for success
if all_assigned && correct_count && distribution_correct && persistence_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
# Always cleanup test data (inbox, agents, conversations, policies)
# This prevents test data from accumulating in the database
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code (0 = success, 1 = failure)
exit(TestBasicAssignment.new.run ? 0 : 1)
@@ -1,206 +0,0 @@
# frozen_string_literal: true
# Test: Agent Capacity Limits (Enterprise)
# Run with: bundle exec rails runner script/assignment_v2/test_capacity_limits.rb
#
# Tests:
# - Agents respect inbox-specific capacity limits
# - When agent reaches limit, new conversations go to other agents
# - Agents without capacity policy have unlimited capacity
# - Capacity is per-inbox (agent can have different limits for different inboxes)
#
# Key Implementation Details:
# - AgentCapacityPolicy is linked to AccountUser (not User directly)
# - InboxCapacityLimit defines conversation_limit per inbox for a policy
# - CapacityService.agent_has_capacity? checks: current_open_conversations < limit
# - Enterprise::AutoAssignment::AssignmentService#filter_agents_by_capacity filters agents
# - Capacity filtering only happens when:
# 1. assignment_v2 feature is enabled
# 2. Account has at least one AccountUser with agent_capacity_policy
require_relative 'test_helpers'
class TestCapacityLimits
include AssignmentV2TestHelpers
def run
skip_if_not_enterprise
section('TEST: Agent Capacity Limits (Enterprise)')
account = get_test_account
inbox = nil
begin
# Setup test inbox and policy
inbox = create_test_inbox(account, name: 'Capacity Test Inbox')
policy = create_test_policy(account, name: 'Capacity Test Policy')
link_policy_to_inbox(inbox, policy)
# Test 1: Agents with capacity limits
section('Test 1: Agents Respect Capacity Limits')
# Create 2 agents
agent_limited = create_test_agent(account, inbox, name: 'Agent Limited', online: true)
agent_unlimited = create_test_agent(account, inbox, name: 'Agent Unlimited', online: true)
# Create capacity policy with limit of 3 conversations per agent for this inbox
capacity_policy = account.agent_capacity_policies.create!(
name: "Capacity Policy #{SecureRandom.hex(4)}",
exclusion_rules: {}
)
# Link capacity policy to the inbox with a limit of 3
capacity_policy.inbox_capacity_limits.create!(
inbox: inbox,
conversation_limit: 3
)
# Link capacity policy to agent_limited's account_user
account_user_limited = account.account_users.find_by(user: agent_limited)
account_user_limited.update!(agent_capacity_policy: capacity_policy)
info("#{agent_limited.name} has capacity limit of 3 for inbox #{inbox.id}")
info("#{agent_unlimited.name} has no capacity limit")
# Create 8 conversations
# Expected distribution:
# - agent_limited: 3 (hits capacity limit)
# - agent_unlimited: 5 (takes remaining conversations)
create_bulk_conversations(inbox, count: 8)
# Run assignment
assigned_count = run_assignment(inbox)
# Verify capacity limits respected
log('Verifying capacity limits...', color: :blue)
# All 8 should be assigned
all_assigned = assert_equal(
assigned_count,
8,
'All 8 conversations assigned'
)
# Agent_limited should have exactly 3 (at capacity limit)
limited_count = inbox.conversations.where(assignee: agent_limited).count
limited_at_capacity = assert_equal(
limited_count,
3,
"#{agent_limited.name} has 3 conversations (at capacity)"
)
# Agent_unlimited should have 5 (took the rest)
unlimited_count = inbox.conversations.where(assignee: agent_unlimited).count
unlimited_got_rest = assert_equal(
unlimited_count,
5,
"#{agent_unlimited.name} has 5 conversations (no limit)"
)
test1_ok = all_assigned && limited_at_capacity && unlimited_got_rest
# Test 2: More conversations arrive - only unlimited agent gets them
section('Test 2: Limited Agent at Capacity')
# Create 3 more conversations
create_bulk_conversations(inbox, count: 3)
# Run assignment
assigned_count_2 = run_assignment(inbox)
# Verify only unlimited agent gets assignments
log('Verifying only unlimited agent gets new assignments...', color: :blue)
# All 3 should be assigned
all_assigned_2 = assert_equal(
assigned_count_2,
3,
'All 3 new conversations assigned'
)
# Agent_limited still has 3 (at capacity, gets no new ones)
limited_still_count = inbox.conversations.where(assignee: agent_limited).count
limited_unchanged = assert_equal(
limited_still_count,
3,
"#{agent_limited.name} still has 3 (at capacity)"
)
# Agent_unlimited now has 8 (5 + 3)
unlimited_new_count = inbox.conversations.where(assignee: agent_unlimited).count
unlimited_got_all_new = assert_equal(
unlimited_new_count,
8,
"#{agent_unlimited.name} has 8 conversations (got all new ones)"
)
test2_ok = all_assigned_2 && limited_unchanged && unlimited_got_all_new
# Test 3: Resolve some conversations - capacity frees up
section('Test 3: Capacity Frees Up When Conversations Resolve')
# Resolve 2 of agent_limited's conversations
agent_limited_conversations = inbox.conversations.where(assignee: agent_limited).limit(2)
agent_limited_conversations.each { |conv| conv.update!(status: 'resolved') }
info("Resolved 2 of #{agent_limited.name}'s conversations")
# Create 4 more conversations
create_bulk_conversations(inbox, count: 4)
# Run assignment
assigned_count_3 = run_assignment(inbox)
# Verify agent_limited can get assignments again (up to capacity)
log('Verifying capacity freed up...', color: :blue)
# All 4 should be assigned
all_assigned_3 = assert_equal(
assigned_count_3,
4,
'All 4 final conversations assigned'
)
# Agent_limited should have 3 open conversations again (was 1, got 2 more)
limited_open_count = inbox.conversations.where(assignee: agent_limited, status: 'open').count
limited_filled_capacity = assert_equal(
limited_open_count,
3,
"#{agent_limited.name} has 3 open conversations (capacity filled again)"
)
# Agent_unlimited should have 10 open conversations (8 + 2)
unlimited_final_count = inbox.conversations.where(assignee: agent_unlimited, status: 'open').count
unlimited_got_remainder = assert_equal(
unlimited_final_count,
10,
"#{agent_unlimited.name} has 10 open conversations (got remainder)"
)
test3_ok = all_assigned_3 && limited_filled_capacity && unlimited_got_remainder
# Show final distribution (open only)
log('Final Distribution (open conversations only):', color: :blue)
puts " #{agent_limited.name}: #{limited_open_count} conversations (limit: 3)"
puts " #{agent_unlimited.name}: #{unlimited_final_count} conversations (no limit)"
# Final result
if test1_ok && test2_ok && test3_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestCapacityLimits.new.run ? 0 : 1)
-266
View File
@@ -1,266 +0,0 @@
# frozen_string_literal: true
# Test: Edge Cases and Error Scenarios
# Run with: bundle exec rails runner script/assignment_v2/test_edge_cases.rb
#
# Tests:
# - No agents online: assignment returns 0, conversations remain unassigned
# - No conversations to assign: assignment completes without error
# - Policy disabled: assignment still works (policy just not enforced)
# - Auto-assignment disabled on inbox: assignment returns 0
# - All agents at rate limit: no assignments happen
# - Resolved/snoozed conversations: not assigned (only open+unassigned)
#
# Key Implementation Details:
# - AssignmentService.perform_bulk_assignment returns 0 if no eligible conversations
# - inbox.auto_assignment_v2_enabled? checks inbox.enable_auto_assignment flag
# - Only conversations with status='open' and assignee_id=nil are eligible
# - Policy.enabled flag doesn't block assignment, just affects policy behavior
# - When no agents available, assignment gracefully returns 0 (no errors)
require_relative 'test_helpers'
class TestEdgeCases
include AssignmentV2TestHelpers
def run
section('TEST: Edge Cases and Error Scenarios')
account = get_test_account
inbox = nil
begin
# Test 1: No agents online
section('Test 1: No Agents Online')
inbox = create_test_inbox(account, name: 'Edge Case Test Inbox')
policy = create_test_policy(account, name: 'Edge Case Policy')
link_policy_to_inbox(inbox, policy)
# Create agent but keep them offline
agent_offline = create_test_agent(account, inbox, name: 'Agent Offline', online: false)
info("Created offline agent: #{agent_offline.name}")
# Create 3 conversations
create_bulk_conversations(inbox, count: 3)
# Run assignment - should assign 0 (no online agents)
assigned_count = run_assignment(inbox)
log('Verifying no agents online...', color: :blue)
# Should assign 0
none_assigned = assert_equal(
assigned_count,
0,
'No conversations assigned (no online agents)'
)
# All conversations should remain unassigned
still_unassigned = assert_equal(
inbox.conversations.unassigned.count,
3,
'All 3 conversations remain unassigned'
)
test1_ok = none_assigned && still_unassigned
# Test 2: No conversations to assign
section('Test 2: No Conversations to Assign')
# Bring agent online
OnlineStatusTracker.update_presence(account.id, 'User', agent_offline.id)
OnlineStatusTracker.set_status(account.id, agent_offline.id, 'online')
info("Brought #{agent_offline.name} online")
# Assign all existing conversations manually
inbox.conversations.unassigned.each { |conv| conv.update!(assignee: agent_offline) }
info('Manually assigned all existing conversations')
# Run assignment with no unassigned conversations
assigned_count_2 = run_assignment(inbox)
log('Verifying no conversations to assign...', color: :blue)
# Should assign 0 (nothing to assign)
none_to_assign = assert_equal(
assigned_count_2,
0,
'No conversations assigned (none available)'
)
# No unassigned conversations
no_unassigned = assert_equal(
inbox.conversations.unassigned.count,
0,
'No unassigned conversations'
)
test2_ok = none_to_assign && no_unassigned
# Test 3: Policy disabled (note: inbox.enable_auto_assignment not yet implemented)
section('Test 3: Policy Disabled')
# Create new conversations
create_bulk_conversations(inbox, count: 3)
# Disable policy
policy.update!(enabled: false)
info('Disabled policy')
# Run assignment - policy disabled doesn't block assignment in current implementation
# Policy.enabled is intended for future use but doesn't currently affect assignment
run_assignment(inbox)
log('Verifying policy disabled (no effect currently)...', color: :blue)
# NOTE: Policy.enabled doesn't actually block assignment yet, so this will assign
# We're testing that assignment doesn't crash when policy is disabled
policy_disabled_ok = assert(
true, # Just verify no crash
'Assignment runs without error when policy disabled',
'Assignment crashed when policy disabled'
)
# Re-enable policy for next tests
policy.update!(enabled: true)
test3_ok = policy_disabled_ok
# Test 4: All agents at rate limit
section('Test 4: All Agents at Rate Limit')
# Clear all conversations and start fresh
inbox.conversations.destroy_all
info('Cleared all conversations for rate limit test')
# Update existing policy with very low rate limit
policy.update!(
fair_distribution_limit: 1,
fair_distribution_window: 3600
)
# Re-link to sync config to inbox
link_policy_to_inbox(inbox, policy)
info('Updated policy: rate limit = 1 conversation per agent')
# Create 3 new conversations
create_bulk_conversations(inbox, count: 3)
# Run assignment once - should assign 1 (hitting the limit)
assigned_count_first = run_assignment(inbox)
info("First assignment: assigned #{assigned_count_first} conversation(s), agent now at limit")
# Verify first assignment worked
first_assign_ok = assert_equal(
assigned_count_first,
1,
'First assignment assigned 1 conversation (limit reached)'
)
# Now agent is at limit (1/1), 2 conversations remain unassigned
# Run assignment again - should assign 0 (agent at limit)
assigned_count_4 = run_assignment(inbox)
log('Verifying all agents at limit...', color: :blue)
# Should assign 0
at_limit_ok = assert_equal(
assigned_count_4,
0,
'No conversations assigned (all agents at rate limit)'
)
# 2 conversations remain unassigned
unassigned_at_limit = assert_equal(
inbox.conversations.unassigned.count,
2,
'2 conversations remain unassigned'
)
test4_ok = first_assign_ok && at_limit_ok && unassigned_at_limit
# Test 5: Only resolved/snoozed conversations (not eligible)
section('Test 5: Only Resolved/Snoozed Conversations')
# Create new inbox to start fresh
inbox2 = create_test_inbox(account, name: 'Status Test Inbox')
policy2 = create_test_policy(account, name: 'Status Test Policy')
link_policy_to_inbox(inbox2, policy2)
create_test_agent(account, inbox2, name: 'Agent Status Test', online: true)
# Create conversations with different statuses
conv_resolved = create_test_conversation(inbox2, contact_name: 'Resolved Customer', status: 'resolved')
conv_snoozed = create_test_conversation(inbox2, contact_name: 'Snoozed Customer', status: 'snoozed')
conv_pending = create_test_conversation(inbox2, contact_name: 'Pending Customer', status: 'pending')
info('Created conversations with statuses: resolved, snoozed, pending')
# Run assignment - should assign 0 (no open conversations)
assigned_count_5 = run_assignment(inbox2)
log('Verifying non-open conversations not assigned...', color: :blue)
# Should assign 0
status_ok = assert_equal(
assigned_count_5,
0,
'No conversations assigned (all are resolved/snoozed/pending)'
)
# All conversations remain unassigned
conv_resolved.reload
conv_snoozed.reload
conv_pending.reload
none_assigned_5 = assert(
conv_resolved.assignee_id.nil? &&
conv_snoozed.assignee_id.nil? &&
conv_pending.assignee_id.nil?,
'All non-open conversations remain unassigned',
'Some non-open conversations were assigned'
)
# Create 1 open conversation - should be assigned
conv_open = create_test_conversation(inbox2, contact_name: 'Open Customer', status: 'open')
assigned_count_6 = run_assignment(inbox2)
# Should assign 1
open_assigned = assert_equal(
assigned_count_6,
1,
'Open conversation assigned'
)
conv_open.reload
open_has_assignee = assert(
conv_open.assignee_id.present?,
'Open conversation has assignee',
'Open conversation not assigned'
)
test5_ok = status_ok && none_assigned_5 && open_assigned && open_has_assignee
# Cleanup inbox2
cleanup_test_data(account, inbox: inbox2)
# Final result
if test1_ok && test2_ok && test3_ok && test4_ok && test5_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestEdgeCases.new.run ? 0 : 1)
@@ -1,307 +0,0 @@
# frozen_string_literal: true
# Test: Conversation Exclusion Rules (Enterprise)
# Run with: bundle exec rails runner script/assignment_v2/test_exclusion_rules.rb
#
# Tests:
# - Conversations with excluded labels are not assigned
# - Conversations older than threshold are not assigned
# - Multiple exclusion rules work together
# - Conversations without exclusions are assigned normally
#
# Key Implementation Details:
# - Exclusion rules are stored in AgentCapacityPolicy#exclusion_rules (JSONB)
# - exclusion_rules format: { excluded_labels: ['vip', 'escalated'], exclude_older_than_hours: 24 }
# - Enterprise::AutoAssignment::AssignmentService#apply_exclusion_rules filters conversations
# - Label exclusions use: scope.tagged_with(labels, exclude: true, on: :labels)
# - Age exclusions use: scope.where('created_at >= ?', hours.hours.ago)
# - Exclusions apply BEFORE conversations are fetched for assignment
require_relative 'test_helpers'
class TestExclusionRules
include AssignmentV2TestHelpers
def run
skip_if_not_enterprise
section('TEST: Conversation Exclusion Rules (Enterprise)')
account = get_test_account
inbox = nil
begin
# Setup test inbox and policy
inbox = create_test_inbox(account, name: 'Exclusion Test Inbox')
policy = create_test_policy(account, name: 'Exclusion Test Policy')
link_policy_to_inbox(inbox, policy)
# Test 1: Label exclusions
section('Test 1: Exclude Conversations by Label')
# Create agent
create_test_agent(account, inbox, name: 'Agent Exclusion Test', online: true)
# Create capacity policy with label exclusions
capacity_policy = account.agent_capacity_policies.create!(
name: "Exclusion Policy #{SecureRandom.hex(4)}",
exclusion_rules: {
'excluded_labels' => %w[vip escalated]
}
)
# Link capacity policy to inbox
capacity_policy.inbox_capacity_limits.create!(
inbox: inbox,
conversation_limit: 100 # High limit to ensure exclusions are the only filter
)
info('Created exclusion rule: exclude conversations with labels [vip, escalated]')
# Create conversations: 2 with excluded labels, 2 without
conv_vip = create_test_conversation(inbox, contact_name: 'VIP Customer')
conv_vip.label_list.add('vip')
conv_vip.save!
conv_escalated = create_test_conversation(inbox, contact_name: 'Escalated Customer')
conv_escalated.label_list.add('escalated')
conv_escalated.save!
conv_normal_1 = create_test_conversation(inbox, contact_name: 'Normal Customer 1')
conv_normal_2 = create_test_conversation(inbox, contact_name: 'Normal Customer 2')
info('Created 4 conversations: 2 with excluded labels, 2 without')
# Run assignment
assigned_count = run_assignment(inbox)
# Verify only non-excluded conversations assigned
log('Verifying label exclusions...', color: :blue)
# Should assign only 2 conversations (the ones without excluded labels)
correct_count = assert_equal(
assigned_count,
2,
'Only 2 conversations assigned (excluded label conversations skipped)'
)
# VIP conversation should remain unassigned
conv_vip.reload
vip_excluded = assert(
conv_vip.assignee_id.nil?,
'VIP conversation remains unassigned',
'VIP conversation was assigned (should be excluded)'
)
# Escalated conversation should remain unassigned
conv_escalated.reload
escalated_excluded = assert(
conv_escalated.assignee_id.nil?,
'Escalated conversation remains unassigned',
'Escalated conversation was assigned (should be excluded)'
)
# Normal conversations should be assigned
conv_normal_1.reload
conv_normal_2.reload
normals_assigned = assert(
conv_normal_1.assignee_id.present? && conv_normal_2.assignee_id.present?,
'Normal conversations assigned',
'Normal conversations not assigned'
)
test1_ok = correct_count && vip_excluded && escalated_excluded && normals_assigned
# Test 2: Age exclusions
section('Test 2: Exclude Conversations by Age')
# Clear previous test data
inbox.conversations.destroy_all
# Update exclusion rules to exclude conversations older than 1 hour
capacity_policy.update!(
exclusion_rules: {
'exclude_older_than_hours' => 1
}
)
info('Updated exclusion rule: exclude conversations older than 1 hour')
# Create conversations: 2 old (>1 hour), 2 recent (<1 hour)
conv_old_1 = create_test_conversation(
inbox,
contact_name: 'Old Customer 1',
created_at: 2.hours.ago,
last_activity_at: 2.hours.ago
)
conv_old_2 = create_test_conversation(
inbox,
contact_name: 'Old Customer 2',
created_at: 90.minutes.ago,
last_activity_at: 90.minutes.ago
)
conv_recent_1 = create_test_conversation(
inbox,
contact_name: 'Recent Customer 1',
created_at: 30.minutes.ago,
last_activity_at: 30.minutes.ago
)
conv_recent_2 = create_test_conversation(
inbox,
contact_name: 'Recent Customer 2',
created_at: 10.minutes.ago,
last_activity_at: 10.minutes.ago
)
info('Created 4 conversations: 2 older than 1 hour, 2 recent')
# Run assignment
assigned_count_2 = run_assignment(inbox)
# Verify only recent conversations assigned
log('Verifying age exclusions...', color: :blue)
# Should assign only 2 conversations (the recent ones)
correct_count_2 = assert_equal(
assigned_count_2,
2,
'Only 2 conversations assigned (old conversations excluded)'
)
# Old conversations should remain unassigned
conv_old_1.reload
conv_old_2.reload
old_excluded = assert(
conv_old_1.assignee_id.nil? && conv_old_2.assignee_id.nil?,
'Old conversations remain unassigned',
'Old conversations were assigned (should be excluded)'
)
# Recent conversations should be assigned
conv_recent_1.reload
conv_recent_2.reload
recent_assigned = assert(
conv_recent_1.assignee_id.present? && conv_recent_2.assignee_id.present?,
'Recent conversations assigned',
'Recent conversations not assigned'
)
test2_ok = correct_count_2 && old_excluded && recent_assigned
# Test 3: Combined exclusions (both label and age)
section('Test 3: Combined Label and Age Exclusions')
# Clear previous test data
inbox.conversations.destroy_all
# Update exclusion rules to have both label and age exclusions
capacity_policy.update!(
exclusion_rules: {
'excluded_labels' => ['urgent'],
'exclude_older_than_hours' => 1
}
)
info('Updated exclusion rules: exclude [urgent] labels AND conversations older than 1 hour')
# Create 5 conversations with different combinations
# 1. Recent + no label = should be assigned
conv_good = create_test_conversation(
inbox,
contact_name: 'Good Customer',
created_at: 30.minutes.ago
)
# 2. Old + no label = excluded by age
conv_old = create_test_conversation(
inbox,
contact_name: 'Old Customer',
created_at: 2.hours.ago
)
# 3. Recent + urgent label = excluded by label
conv_urgent = create_test_conversation(
inbox,
contact_name: 'Urgent Customer',
created_at: 30.minutes.ago
)
conv_urgent.label_list.add('urgent')
conv_urgent.save!
# 4. Old + urgent label = excluded by both (double excluded)
conv_double_excluded = create_test_conversation(
inbox,
contact_name: 'Old Urgent Customer',
created_at: 2.hours.ago
)
conv_double_excluded.label_list.add('urgent')
conv_double_excluded.save!
# 5. Recent + normal label = should be assigned
conv_good_2 = create_test_conversation(
inbox,
contact_name: 'Good Customer 2',
created_at: 20.minutes.ago
)
conv_good_2.label_list.add('normal')
conv_good_2.save!
info('Created 5 conversations: 1 old, 1 urgent, 1 old+urgent, 2 assignable')
# Run assignment
assigned_count_3 = run_assignment(inbox)
# Verify combined exclusions
log('Verifying combined exclusions...', color: :blue)
# Should assign only 2 conversations (the ones passing both filters)
correct_count_3 = assert_equal(
assigned_count_3,
2,
'Only 2 conversations assigned (combined exclusions work)'
)
# Excluded conversations should remain unassigned
conv_old.reload
conv_urgent.reload
conv_double_excluded.reload
all_excluded = assert(
conv_old.assignee_id.nil? &&
conv_urgent.assignee_id.nil? &&
conv_double_excluded.assignee_id.nil?,
'All excluded conversations remain unassigned',
'Some excluded conversations were assigned'
)
# Good conversations should be assigned
conv_good.reload
conv_good_2.reload
good_assigned = assert(
conv_good.assignee_id.present? && conv_good_2.assignee_id.present?,
'Valid conversations assigned',
'Valid conversations not assigned'
)
test3_ok = correct_count_3 && all_excluded && good_assigned
# Final result
if test1_ok && test2_ok && test3_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestExclusionRules.new.run ? 0 : 1)
-306
View File
@@ -1,306 +0,0 @@
# frozen_string_literal: true
# Shared test helpers for Assignment v2 feature testing
#
# Key Design Decisions & Fixes:
#
# 1. Password Requirements:
# - Users require complex passwords: 'Password123!' (uppercase + special char)
#
# 2. Unique Naming:
# - All entities (inboxes, policies, agents) get unique names using SecureRandom.hex(4)
# - Prevents validation errors across multiple test runs
#
# 3. Conversation Creation:
# - Uses FactoryBot to handle complex associations automatically
# - ContactInbox requires source_id (cannot be blank)
# - FactoryBot factory handles: contact → contact_inbox → conversation chain
#
# 4. Policy Linking:
# - RateLimiter reads from inbox.auto_assignment_config (not policy directly)
# - link_policy_to_inbox syncs policy settings to inbox config
# - Must call inbox.reload after linking to pick up association
#
# 5. Redis Access:
# - Use Redis::Alfred.keys_count() instead of Redis::Alfred.redis.keys.count
# - Use Redis::Alfred.scan_each() instead of Redis::Alfred.redis.keys for cleanup
#
# 6. Assignment Job:
# - Must use keyword argument: perform_now(inbox_id: id)
# - Not positional: perform_now(id) will fail
#
module AssignmentV2TestHelpers
# Default test account ID
DEFAULT_ACCOUNT_ID = 2
# Color codes for terminal output
COLORS = {
green: "\e[32m",
red: "\e[31m",
yellow: "\e[33m",
blue: "\e[34m",
reset: "\e[0m"
}.freeze
def log(msg, prefix: '==>', color: nil)
colored_prefix = color ? "#{COLORS[color]}#{prefix}#{COLORS[:reset]}" : prefix
puts "\n#{colored_prefix} #{msg}"
end
def success(msg)
log(msg, prefix: '✓', color: :green)
end
def error(msg)
log(msg, prefix: '✗', color: :red)
end
def warning(msg)
log(msg, prefix: '⚠', color: :yellow)
end
def info(msg)
log(msg, prefix: '', color: :blue)
end
def section(title)
puts "\n#{'=' * 60}"
puts " #{title}"
puts '=' * 60
end
def assert(condition, success_msg, error_msg)
if condition
success(success_msg)
true
else
error(error_msg)
false
end
end
def assert_equal(actual, expected, description)
if actual == expected
success("#{description}: #{actual}")
true
else
error("#{description}: Expected #{expected}, got #{actual}")
false
end
end
def get_test_account
account = Account.find_by(id: DEFAULT_ACCOUNT_ID)
raise "Account #{DEFAULT_ACCOUNT_ID} not found. Please create it first." unless account
# Enable assignment_v2 feature
account.enable_features('assignment_v2')
account
end
def create_test_inbox(account, name: 'Test Inbox')
unique_name = "#{name} #{SecureRandom.hex(4)}"
channel = Channel::WebWidget.create!(
account: account,
website_url: 'https://test.com',
widget_color: '#0000FF'
)
inbox = account.inboxes.create!(
name: unique_name,
channel: channel,
enable_auto_assignment: true
)
info("Created inbox: #{inbox.name} (ID: #{inbox.id})")
inbox
end
def create_test_policy(account, **options)
defaults = {
name: 'Test Policy',
conversation_priority: 'earliest_created',
enabled: true
}
merged_options = defaults.merge(options)
# Always make name unique
merged_options[:name] = "#{merged_options[:name]} #{SecureRandom.hex(4)}"
policy = account.assignment_policies.create!(merged_options)
info("Created policy: #{policy.name} (ID: #{policy.id})")
policy
end
def link_policy_to_inbox(inbox, policy)
InboxAssignmentPolicy.find_or_create_by!(
inbox: inbox,
assignment_policy: policy
)
# Also set inbox config for rate limiter (RateLimiter reads from inbox.auto_assignment_config)
inbox.update!(
auto_assignment_config: {
'fair_distribution_limit' => policy.fair_distribution_limit,
'fair_distribution_window' => policy.fair_distribution_window,
'conversation_priority' => policy.conversation_priority
}.compact
)
inbox.reload # Reload to pick up the association
info("Linked policy #{policy.id} to inbox #{inbox.id}")
inbox
end
def create_test_agent(account, inbox, name:, email: nil, online: true)
unique_id = SecureRandom.hex(4)
email ||= "#{name.downcase.tr(' ', '_')}_#{unique_id}@test.com"
unique_name = "#{name} #{unique_id}"
user = account.users.create!(
email: email,
password: 'Password123!',
password_confirmation: 'Password123!',
name: unique_name,
confirmed_at: Time.zone.now
)
# Add to inbox
inbox.inbox_members.create!(user: user)
# Set online status
# Note: inbox.available_agents filters by status == 'online', so we set both presence and status
if online
OnlineStatusTracker.update_presence(account.id, 'User', user.id)
OnlineStatusTracker.set_status(account.id, user.id, 'online')
else
# For offline agents, set status to 'offline' (they won't be in available_agents)
OnlineStatusTracker.set_status(account.id, user.id, 'offline')
end
info("Created agent: #{user.name} (#{user.email}, online: #{online})")
user
end
def create_test_conversation(inbox, **options)
contact_name = options.delete(:contact_name) || "Customer #{SecureRandom.hex(4)}"
# Use FactoryBot if available
if defined?(FactoryBot)
FactoryBot.create(
:conversation,
options.merge(
inbox: inbox,
account: inbox.account,
contact: FactoryBot.create(:contact, account: inbox.account, name: contact_name)
)
)
else
# Fallback to manual creation
defaults = {
status: 'open',
assignee_id: nil,
created_at: Time.zone.now,
last_activity_at: Time.zone.now
}
contact = inbox.contacts.create!(
account: inbox.account,
name: contact_name
)
contact_inbox = ContactInbox.create!(
contact: contact,
inbox: inbox,
source_id: "test_#{SecureRandom.uuid}"
)
inbox.conversations.create!(
defaults.merge(options).merge(
account: inbox.account,
contact: contact,
contact_inbox: contact_inbox
)
)
end
end
def create_bulk_conversations(inbox, count:, **options)
conversations = count.times.map do |i|
create_test_conversation(
inbox,
**options, contact_name: "Customer #{i + 1}",
created_at: (count - i).minutes.ago,
last_activity_at: options[:last_activity_at] || (count - i).minutes.ago
)
end
info("Created #{count} test conversations")
conversations
end
def run_assignment(inbox)
info("Running assignment job for inbox #{inbox.id}...")
before_count = inbox.conversations.unassigned.count
AutoAssignment::AssignmentJob.perform_now(inbox_id: inbox.id)
after_count = inbox.conversations.unassigned.count
assigned_count = before_count - after_count
info("Assigned: #{assigned_count} conversations (#{before_count}#{after_count} unassigned)")
assigned_count
end
def show_assignment_distribution(inbox, agents)
log('Assignment Distribution:', color: :blue)
agents.each do |agent|
count = inbox.conversations.where(assignee: agent, status: 'open').count
puts " #{agent.name}: #{count} conversations"
end
unassigned = inbox.conversations.unassigned.count
puts " Unassigned: #{unassigned} conversations"
end
def count_redis_keys(pattern)
Redis::Alfred.keys_count(pattern)
end
def cleanup_redis_keys(pattern)
# Use scan to avoid blocking Redis with KEYS command
Redis::Alfred.scan_each(match: pattern) do |key|
Redis::Alfred.del(key)
end
end
def cleanup_test_data(account, inbox: nil)
if inbox
info("Cleaning up inbox #{inbox.id}...")
inbox.conversations.destroy_all
inbox.inbox_members.destroy_all
InboxAssignmentPolicy.where(inbox: inbox).destroy_all
inbox.destroy
else
info("Cleaning up account #{account.id} test data...")
account.conversations.where('created_at > ?', 1.hour.ago).destroy_all
account.inbox_members.joins(:user).where(users: { email: [/.+@test\.com/] }).destroy_all
account.inboxes.where('name LIKE ?', '%Test%').destroy_all
account.assignment_policies.where('name LIKE ?', '%Test%').destroy_all
account.users.where('email LIKE ?', '%@test.com%').destroy_all
end
success('Cleanup complete')
end
def enterprise_available?
defined?(Enterprise)
end
def skip_if_not_enterprise
return if enterprise_available?
warning('Enterprise features not available, skipping test')
exit(0)
end
end
-225
View File
@@ -1,225 +0,0 @@
# frozen_string_literal: true
# Test: Conversation Priority Modes
# Run with: bundle exec rails runner script/assignment_v2/test_priority_modes.rb
#
# Tests:
# - longest_waiting mode prioritizes conversations by oldest last_activity_at
# - earliest_created mode (default) prioritizes by oldest created_at
# - Assignment respects policy priority setting
# - Edge case: longest_waiting uses created_at as tiebreaker
#
# Key Implementation Details:
# - Priority is set via policy.conversation_priority enum ('longest_waiting' or 'earliest_created')
# - Enterprise::AutoAssignment::AssignmentService applies priority via unassigned_conversations method
# - longest_waiting: ORDER BY last_activity_at ASC, created_at ASC (uses created_at as tiebreaker)
# - earliest_created: ORDER BY created_at ASC (pure FIFO)
# - Priority affects which conversations are fetched and assigned first
require_relative 'test_helpers'
class TestPriorityModes
include AssignmentV2TestHelpers
def run
section('TEST: Conversation Priority Modes')
account = get_test_account
inbox_longest = nil
inbox_earliest = nil
begin
# Test 1: longest_waiting mode
section('Test 1: Longest Waiting Mode')
inbox_longest = create_test_inbox(account, name: 'Longest Waiting Test')
policy_longest = create_test_policy(
account,
name: 'Longest Waiting Policy',
conversation_priority: 'longest_waiting'
)
link_policy_to_inbox(inbox_longest, policy_longest)
# Create 1 agent for this test (to control assignment order)
create_test_agent(account, inbox_longest, name: 'Agent Priority Test', online: true)
# Create conversations with specific last_activity_at times
# Conversation order by last_activity_at (oldest first):
# conv_c (30 min ago) -> conv_b (20 min ago) -> conv_a (10 min ago)
# But created_at order is: conv_a -> conv_b -> conv_c
info('Creating conversations with varied last_activity_at times...')
conv_a = create_test_conversation(
inbox_longest,
contact_name: 'Customer A',
created_at: 30.minutes.ago,
last_activity_at: 10.minutes.ago # Most recent activity
)
conv_b = create_test_conversation(
inbox_longest,
contact_name: 'Customer B',
created_at: 20.minutes.ago,
last_activity_at: 20.minutes.ago # Middle activity
)
conv_c = create_test_conversation(
inbox_longest,
contact_name: 'Customer C',
created_at: 10.minutes.ago,
last_activity_at: 30.minutes.ago # Oldest activity (should be first)
)
info("Conv A: created #{conv_a.created_at}, activity #{conv_a.last_activity_at}")
info("Conv B: created #{conv_b.created_at}, activity #{conv_b.last_activity_at}")
info("Conv C: created #{conv_c.created_at}, activity #{conv_c.last_activity_at}")
# Run assignment - should only assign 1 conversation (the oldest by last_activity_at)
# We'll run assignment 3 times to see the order
info('Running first assignment (should assign conv_c - oldest activity)...')
run_assignment(inbox_longest)
conv_c.reload
first_assigned = conv_c
info('Running second assignment (should assign conv_b - middle activity)...')
run_assignment(inbox_longest)
conv_b.reload
second_assigned = conv_b
info('Running third assignment (should assign conv_a - newest activity)...')
run_assignment(inbox_longest)
conv_a.reload
third_assigned = conv_a
# Verify longest_waiting prioritization
log('Verifying longest_waiting order...', color: :blue)
# Conv C should be assigned (oldest last_activity_at)
order_correct_1 = assert(
first_assigned == conv_c && conv_c.assignee_id.present?,
'First assigned: conv_c (oldest last_activity_at: 30 min ago)',
"First assigned should be conv_c, got: #{first_assigned.contact.name}"
)
# Conv B should be assigned next (middle last_activity_at)
order_correct_2 = assert(
second_assigned == conv_b && conv_b.assignee_id.present?,
'Second assigned: conv_b (middle last_activity_at: 20 min ago)',
"Second assigned should be conv_b, got: #{second_assigned.contact.name}"
)
# Conv A should be assigned last (newest last_activity_at)
order_correct_3 = assert(
third_assigned == conv_a && conv_a.assignee_id.present?,
'Third assigned: conv_a (newest last_activity_at: 10 min ago)',
"Third assigned should be conv_a, got: #{third_assigned.contact.name}"
)
longest_waiting_ok = order_correct_1 && order_correct_2 && order_correct_3
# Test 2: earliest_created mode (default FIFO)
section('Test 2: Earliest Created Mode (Default FIFO)')
inbox_earliest = create_test_inbox(account, name: 'Earliest Created Test')
policy_earliest = create_test_policy(
account,
name: 'Earliest Created Policy',
conversation_priority: 'earliest_created'
)
link_policy_to_inbox(inbox_earliest, policy_earliest)
# Create 1 agent for this test
create_test_agent(account, inbox_earliest, name: 'Agent FIFO Test', online: true)
# Create conversations with specific created_at times
# Same last_activity_at for all to ensure created_at is the only factor
# Order by created_at (oldest first): conv_x -> conv_y -> conv_z
info('Creating conversations with varied created_at times...')
conv_x = create_test_conversation(
inbox_earliest,
contact_name: 'Customer X',
created_at: 30.minutes.ago, # Oldest created (should be first)
last_activity_at: 15.minutes.ago
)
conv_y = create_test_conversation(
inbox_earliest,
contact_name: 'Customer Y',
created_at: 20.minutes.ago, # Middle created
last_activity_at: 15.minutes.ago
)
conv_z = create_test_conversation(
inbox_earliest,
contact_name: 'Customer Z',
created_at: 10.minutes.ago, # Newest created (should be last)
last_activity_at: 15.minutes.ago
)
info("Conv X: created #{conv_x.created_at}, activity #{conv_x.last_activity_at}")
info("Conv Y: created #{conv_y.created_at}, activity #{conv_y.last_activity_at}")
info("Conv Z: created #{conv_z.created_at}, activity #{conv_z.last_activity_at}")
# Run assignment 3 times to see the order
info('Running first assignment (should assign conv_x - oldest created)...')
run_assignment(inbox_earliest)
conv_x.reload
first_assigned_fifo = conv_x
info('Running second assignment (should assign conv_y - middle created)...')
run_assignment(inbox_earliest)
conv_y.reload
second_assigned_fifo = conv_y
info('Running third assignment (should assign conv_z - newest created)...')
run_assignment(inbox_earliest)
conv_z.reload
third_assigned_fifo = conv_z
# Verify earliest_created prioritization
log('Verifying earliest_created (FIFO) order...', color: :blue)
# Conv X should be assigned first (oldest created_at)
fifo_correct_1 = assert(
first_assigned_fifo == conv_x && conv_x.assignee_id.present?,
'First assigned: conv_x (oldest created_at: 30 min ago)',
"First assigned should be conv_x, got: #{first_assigned_fifo.contact.name}"
)
# Conv Y should be assigned next (middle created_at)
fifo_correct_2 = assert(
second_assigned_fifo == conv_y && conv_y.assignee_id.present?,
'Second assigned: conv_y (middle created_at: 20 min ago)',
"Second assigned should be conv_y, got: #{second_assigned_fifo.contact.name}"
)
# Conv Z should be assigned last (newest created_at)
fifo_correct_3 = assert(
third_assigned_fifo == conv_z && conv_z.assignee_id.present?,
'Third assigned: conv_z (newest created_at: 10 min ago)',
"Third assigned should be conv_z, got: #{third_assigned_fifo.contact.name}"
)
earliest_created_ok = fifo_correct_1 && fifo_correct_2 && fifo_correct_3
# Final result
if longest_waiting_ok && earliest_created_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
# Cleanup both inboxes
cleanup_test_data(account, inbox: inbox_longest) if inbox_longest
cleanup_test_data(account, inbox: inbox_earliest) if inbox_earliest
end
end
end
# Run test and exit with appropriate code
exit(TestPriorityModes.new.run ? 0 : 1)
-143
View File
@@ -1,143 +0,0 @@
# frozen_string_literal: true
# Test: Rate Limiting / Fair Distribution
# Run with: bundle exec rails runner script/assignment_v2/test_rate_limiting.rb
#
# Tests:
# - Agents respect fair_distribution_limit
# - Redis keys are created with correct TTL
# - Once all agents hit limit, assignments stop
# - Multiple rounds respect cumulative limits
#
# Key Implementation Details:
# - RateLimiter reads from inbox.auto_assignment_config (not directly from policy)
# - link_policy_to_inbox helper syncs policy settings to inbox config
# - Rate limiting uses Redis keys with pattern: chatwoot:assignment:{inbox_id}:{agent_id}:{conversation_id}
# - Keys have TTL equal to fair_distribution_window (default 3600 seconds)
# - Redis::Alfred.keys_count is used instead of Redis::Alfred.redis.keys.count
# - Rate limit is checked BEFORE assignment, preventing over-assignment
require_relative 'test_helpers'
class TestRateLimiting
include AssignmentV2TestHelpers
def run
section('TEST: Rate Limiting / Fair Distribution')
account = get_test_account
inbox = nil
begin
# Setup test inbox with rate limiting policy
inbox = create_test_inbox(account, name: 'Rate Limiting Test Inbox')
# Create policy with rate limiting enabled
# fair_distribution_limit: Max assignments per agent within time window
# fair_distribution_window: Time window in seconds (1 hour = 3600)
policy = create_test_policy(
account,
name: 'Rate Limiting Policy',
fair_distribution_limit: 3,
fair_distribution_window: 3600
)
# Link policy to inbox
# Fix: Also syncs policy settings to inbox.auto_assignment_config
# because RateLimiter reads from inbox config, not directly from policy
link_policy_to_inbox(inbox, policy)
# Create 2 agents (to test rate limiting across multiple agents)
agents = 2.times.map do |i|
create_test_agent(account, inbox, name: "Agent #{i + 1}", online: true)
end
# Create 10 conversations (more than limit allows: 2 agents × 3 limit = 6)
# This ensures we have leftover conversations to verify rate limiting stops assignment
create_bulk_conversations(inbox, count: 10)
# Run assignment job
assigned_count = run_assignment(inbox)
# Verify rate limiting behavior
log('Verifying rate limiting...', color: :blue)
# Test 1: Only 6 conversations should be assigned (2 agents × 3 limit = 6)
# The remaining 4 should stay unassigned because all agents are at their limit
correct_limit = assert_equal(
assigned_count,
6,
'Assigned count respects rate limit (2 agents × 3 = 6)'
)
# Test 2: Each agent should have exactly 3 conversations (at their limit)
# Round-robin distributes evenly, and both agents hit their limit simultaneously
distribution_ok = true
agents.each do |agent|
count = inbox.conversations.where(assignee: agent).count
if count == 3
success("#{agent.name} has 3 conversations (at limit)")
else
error("#{agent.name} has #{count} conversations (expected 3)")
distribution_ok = false
end
end
# Test 3: 4 conversations should remain unassigned (10 total - 6 assigned)
# These conversations cannot be assigned until rate limit window expires
remaining_ok = assert_equal(
inbox.conversations.unassigned.count,
4,
'Remaining conversations unassigned'
)
# Test 4: Verify Redis keys exist (informational only)
# Keys may not be visible via keys_count due to timing or key expiry
# but the rate limiting behavior proves they exist during assignment
redis_pattern = "chatwoot:assignment:#{inbox.id}:*"
redis_key_count = count_redis_keys(redis_pattern)
info("Redis key count: #{redis_key_count} (pattern: #{redis_pattern})")
# NOTE: keys_count may return 0 due to timing or key expiry, but rate limiting works
redis_ok = true
# Test 5: Try another round - should assign 0 (all agents at limit)
# This verifies that rate limiting persists across multiple job runs
info('Running second assignment round...')
second_round_count = run_assignment(inbox)
second_round_ok = assert_equal(
second_round_count,
0,
'Second round assigns 0 (all agents at limit)'
)
# Show distribution summary
show_assignment_distribution(inbox, agents)
# Clean up Redis keys to avoid affecting other tests
# Uses scan_each instead of keys to avoid blocking Redis
info('Cleaning up Redis keys...')
cleanup_redis_keys(redis_pattern)
success('Redis keys cleaned up')
# Final result: All tests must pass
if correct_limit && distribution_ok && remaining_ok && redis_ok && second_round_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
# Always cleanup test data (inbox, agents, conversations, policies)
# This prevents test data from accumulating in the database
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code (0 = success, 1 = failure)
exit(TestRateLimiting.new.run ? 0 : 1)
@@ -1,245 +0,0 @@
# frozen_string_literal: true
# Test: Real-Time Assignment Trigger
# Run with: bundle exec rails runner script/assignment_v2/test_real_time_trigger.rb
#
# Tests:
# - Assignment triggered when conversation status changes to 'open'
# - Assignment triggered when new conversation created
# - Assignment only runs when assignee is blank
# - Assignment respects enable_auto_assignment flag
#
# Key Implementation Details:
# - AutoAssignmentHandler is a concern included in Conversation model
# - Triggers after_save when conversation_status_changed_to_open?
# - Checks inbox.auto_assignment_v2_enabled? to use v2 vs legacy system
# - NOTE: auto_assignment_v2_enabled? not yet implemented, so this test simulates expected behavior
# - When implemented, it will call: AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
#
# This test manually triggers assignment to simulate real-time behavior
require_relative 'test_helpers'
class TestRealTimeTrigger
include AssignmentV2TestHelpers
def run
section('TEST: Real-Time Assignment Trigger')
account = get_test_account
inbox = nil
begin
# Setup test inbox and policy
inbox = create_test_inbox(account, name: 'Real-Time Trigger Test Inbox')
policy = create_test_policy(account, name: 'Real-Time Policy')
link_policy_to_inbox(inbox, policy)
# Create agent
agent = create_test_agent(account, inbox, name: 'Agent Real-Time', online: true)
# Test 1: Assignment triggered when conversation status changes to open
section('Test 1: Status Change to Open Triggers Assignment')
# Create conversation with status 'pending' (not open)
conv = create_test_conversation(inbox, contact_name: 'Pending Customer', status: 'pending')
info('Created conversation with status: pending')
# Conversation should not be assigned (status is not open)
conv.reload
pending_unassigned = assert(
conv.assignee_id.nil?,
'Pending conversation not assigned',
'Pending conversation was assigned (should not be)'
)
# Change status to 'open' and manually trigger assignment (simulating handler)
conv.update!(status: 'open')
info('Changed status to: open')
# Simulate real-time trigger: run assignment job
# In production, AutoAssignmentHandler would call: AutoAssignment::AssignmentJob.perform_later
assigned_count = run_assignment(inbox)
# Verify assignment happened
log('Verifying assignment after status change...', color: :blue)
assigned_ok = assert_equal(
assigned_count,
1,
'Conversation assigned after status changed to open'
)
conv.reload
has_assignee = assert(
conv.assignee_id.present?,
'Conversation has assignee',
'Conversation not assigned'
)
test1_ok = pending_unassigned && assigned_ok && has_assignee
# Test 2: New conversation created as 'open' triggers assignment
section('Test 2: New Open Conversation Triggers Assignment')
# Create conversation directly as 'open'
conv2 = create_test_conversation(inbox, contact_name: 'Open Customer', status: 'open')
info('Created conversation with status: open')
# Simulate real-time trigger
assigned_count_2 = run_assignment(inbox)
# Verify assignment
log('Verifying assignment for new open conversation...', color: :blue)
assigned_ok_2 = assert_equal(
assigned_count_2,
1,
'New open conversation assigned'
)
conv2.reload
has_assignee_2 = assert(
conv2.assignee_id.present?,
'New conversation has assignee',
'New conversation not assigned'
)
test2_ok = assigned_ok_2 && has_assignee_2
# Test 3: Already assigned conversation not re-assigned
section('Test 3: Already Assigned Conversation Not Re-Assigned')
# Create and assign a conversation
conv3 = create_test_conversation(inbox, contact_name: 'Customer 3', status: 'open')
run_assignment(inbox)
conv3.reload
original_assignee = conv3.assignee
info("Conversation initially assigned to: #{original_assignee.name}")
# Update something else (not status, not assignee)
conv3.update!(additional_attributes: { test: 'value' })
# Simulate real-time trigger
assigned_count_3 = run_assignment(inbox)
# Verify no re-assignment
log('Verifying already-assigned conversation not re-assigned...', color: :blue)
# Should assign 0 (conversation already has assignee)
no_reassign = assert_equal(
assigned_count_3,
0,
'No conversations re-assigned'
)
conv3.reload
same_assignee = assert(
conv3.assignee_id == original_assignee.id,
'Conversation keeps original assignee',
'Conversation was re-assigned (should not be)'
)
test3_ok = no_reassign && same_assignee
# Test 4: Resolved conversation changing to open triggers assignment
section('Test 4: Resolved → Open Triggers Assignment')
# Create and resolve a conversation
conv4 = create_test_conversation(inbox, contact_name: 'Returning Customer', status: 'open')
run_assignment(inbox)
conv4.reload
conv4.update!(status: 'resolved', assignee: nil)
info('Conversation resolved and unassigned')
# Reopen conversation
conv4.update!(status: 'open')
info('Conversation reopened (status: open)')
# Simulate real-time trigger
assigned_count_4 = run_assignment(inbox)
# Verify re-assignment
log('Verifying reopened conversation assigned...', color: :blue)
assigned_ok_4 = assert_equal(
assigned_count_4,
1,
'Reopened conversation assigned'
)
conv4.reload
has_assignee_4 = assert(
conv4.assignee_id.present?,
'Reopened conversation has assignee',
'Reopened conversation not assigned'
)
test4_ok = assigned_ok_4 && has_assignee_4
# Test 5: Assignment respects online status in real-time
section('Test 5: Real-Time Assignment Respects Agent Availability')
# Take agent offline
OnlineStatusTracker.set_status(account.id, agent.id, 'offline')
info("Took #{agent.name} offline")
# Create new conversation
conv5 = create_test_conversation(inbox, contact_name: 'Customer During Offline', status: 'open')
# Simulate real-time trigger
assigned_count_5 = run_assignment(inbox)
# Verify no assignment (agent offline)
log('Verifying no assignment when agent offline...', color: :blue)
no_assign_offline = assert_equal(
assigned_count_5,
0,
'No assignment when agent offline'
)
conv5.reload
unassigned_offline = assert(
conv5.assignee_id.nil?,
'Conversation remains unassigned when agent offline',
'Conversation was assigned despite agent being offline'
)
# Bring agent back online
OnlineStatusTracker.update_presence(account.id, 'User', agent.id)
OnlineStatusTracker.set_status(account.id, agent.id, 'online')
info("Brought #{agent.name} back online")
# Simulate real-time trigger (next conversation triggers assignment for backlog)
assigned_count_6 = run_assignment(inbox)
# Now should assign
assigned_after_online = assert_equal(
assigned_count_6,
1,
'Assignment happens when agent comes online'
)
test5_ok = no_assign_offline && unassigned_offline && assigned_after_online
# Final result
if test1_ok && test2_ok && test3_ok && test4_ok && test5_ok
section('✓ ALL TESTS PASSED')
true
else
section('✗ SOME TESTS FAILED')
false
end
rescue StandardError => e
error("Test failed with error: #{e.message}")
puts e.backtrace.first(5).join("\n")
false
ensure
cleanup_test_data(account, inbox: inbox) if inbox
end
end
end
# Run test and exit with appropriate code
exit(TestRealTimeTrigger.new.run ? 0 : 1)
@@ -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' => {}
}
@@ -3,14 +3,34 @@ require 'rails_helper'
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
describe 'POST /enterprise/webhooks/stripe' do
let(:params) { { content: 'hello' } }
let(:v1_event_params) { { type: 'invoice.created', data: { object: {} } }.to_json }
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
handle_stripe = double
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
it 'delegates to the v1 handler for legacy events' do
handle_stripe = instance_double(Enterprise::Billing::HandleStripeEventService)
event = instance_double(Stripe::Event, type: 'invoice.created')
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
allow(handle_stripe).to receive(:perform)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
expect(handle_stripe).to have_received(:perform).with(event: params)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: v1_event_params, as: :json
expect(handle_stripe).to have_received(:perform).with(event: event)
end
it 'delegates v2 billing events to the v2 webhook handler' do
v2_event_params = { type: 'v2.billing.pricing_plan_subscription.servicing_activated', data: { object: {} } }.to_json
event = instance_double(Stripe::Event, type: 'v2.billing.pricing_plan_subscription.servicing_activated')
handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, perform: { success: true })
allow(Stripe::Webhook).to receive(:construct_event).and_return(event)
allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).and_return(handler_double)
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: v2_event_params, as: :json
expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new)
expect(handler_double).to have_received(:perform).with(event: event)
expect(response).to have_http_status(:ok)
end
it 'returns a bad request if the headers are missing' do
@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do
include ActiveJob::TestHelper
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123')
allow(InstallationConfig).to receive(:find_by).and_call_original
allow(InstallationConfig).to receive(:find_by).with(name: 'STRIPE_METER_ID')
.and_return(instance_double(InstallationConfig, value: 'mtr_test_123'))
end
describe '#perform' do
context 'with no arguments' do
let!(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_123',
'stripe_billing_version' => 2,
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 50
})
end
it 'syncs all accounts with Stripe customer ID' do
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
allow(usage_reporter).to receive(:report).with(50).and_return({ success: true, event_id: 'evt_123' })
result = described_class.new.perform
expect(result).to eq({ synced: 1, failed: 0 })
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
end
context 'with account argument' do
let(:account) do
create(:account, custom_attributes: {
'stripe_customer_id' => 'cus_123',
'captain_responses_usage' => 100,
'stripe_last_synced_credits' => 30
})
end
it 'syncs single account' do
usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService)
allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter)
allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' })
result = described_class.new.perform(account)
expect(result[:success]).to be true
expect(result[:credits_reported]).to eq(70)
expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100)
end
end
end
end
@@ -12,7 +12,7 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
.on_queue('default')
end
it 'executes perform' do
it 'uses V1 customer creation service' do
create_stripe_customer_service = double
allow(Enterprise::Billing::CreateStripeCustomerService)
.to receive(:new)
@@ -1,185 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let(:inbox) { create(:inbox, account: account) }
let(:agent1) { create(:user, account: account, name: 'Agent 1') }
let(:agent2) { create(:user, account: account, name: 'Agent 2') }
let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
before do
# Create inbox members
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Set agents as online
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
end
describe 'exclusion rules' do
let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
let(:label1) { create(:label, account: account, title: 'high-priority') }
let(:label2) { create(:label, account: account, title: 'vip') }
before do
create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
inbox.enable_auto_assignment = true
inbox.save!
end
context 'when excluding conversations by label' do
let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
before do
conversation_with_label.update_labels([label1.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title]
})
end
it 'excludes conversations with specified labels' do
# First check conversations are unassigned
expect(conversation_with_label.assignee).to be_nil
expect(conversation_without_label.assignee).to be_nil
# Run bulk assignment
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only the conversation without label should be assigned
expect(assigned_count).to eq(1)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_present
end
it 'handles bulk assignment correctly' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only 1 conversation should be assigned (the one without label)
expect(assigned_count).to eq(1)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_present
end
it 'excludes conversations with multiple labels' do
conversation_without_label.update_labels([label2.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title, label2.title]
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Both conversations should be excluded
expect(assigned_count).to eq(0)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_nil
end
end
context 'when excluding conversations by age' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
before do
capacity_policy.update!(exclusion_rules: {
'exclude_older_than_hours' => 24
})
end
it 'excludes conversations older than specified hours' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only recent conversation should be assigned
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to be_present
end
it 'handles different time thresholds' do
capacity_policy.update!(exclusion_rules: {
'exclude_older_than_hours' => 2
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only conversation created within 2 hours should be assigned
expect(assigned_count).to eq(1)
expect(recent_conversation.reload.assignee).to be_present
end
end
context 'when combining exclusion rules' do
it 'applies both exclusion rules' do
# Create conversations
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
# Add labels
old_conversation_with_label.update_labels([label1.title])
recent_conversation_with_label.update_labels([label1.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title],
'exclude_older_than_hours' => 24
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only recent conversation without label should be assigned
expect(assigned_count).to eq(1)
expect(old_conversation_with_label.reload.assignee).to be_nil
expect(old_conversation_without_label.reload.assignee).to be_nil
expect(recent_conversation_with_label.reload.assignee).to be_nil
expect(recent_conversation_without_label.reload.assignee).to be_present
end
end
context 'when exclusion rules are empty' do
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
before do
capacity_policy.update!(exclusion_rules: {})
end
it 'assigns all eligible conversations' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(2)
expect(conversation1.reload.assignee).to be_present
expect(conversation2.reload.assignee).to be_present
end
end
context 'when no capacity policy exists' do
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
before do
InboxCapacityLimit.destroy_all
end
it 'assigns all eligible conversations without exclusions' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(2)
expect(conversation1.reload.assignee).to be_present
expect(conversation2.reload.assignee).to be_present
end
end
end
end
@@ -1,88 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:selector) { described_class.new(inbox: inbox) }
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
describe '#select_agent' do
context 'when selecting based on workload' do
let(:available_agents) { [member1, member2, member3] }
it 'selects the agent with least open conversations' do
# Agent1 has 3 open conversations
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
# Agent2 has 1 open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
selected_agent = selector.select_agent(available_agents)
# Should select agent2 as they have the least conversations
expect(selected_agent).to eq(agent2)
end
it 'considers only open conversations' do
# Agent1 has 1 open and 3 resolved conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
# Agent2 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
selected_agent = selector.select_agent([member1, member2])
# Should select agent1 as they have fewer open conversations
expect(selected_agent).to eq(agent1)
end
it 'selects any agent when agents have equal workload' do
# All agents have same number of conversations
[member1, member2, member3].each do |member|
create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
end
selected_agent = selector.select_agent(available_agents)
# Should select one of the agents (when equal, min_by returns the first one it finds)
expect([agent1, agent2, agent3]).to include(selected_agent)
end
end
context 'when no agents are available' do
it 'returns nil' do
selected_agent = selector.select_agent([])
expect(selected_agent).to be_nil
end
end
context 'when one agent is available' do
it 'returns that agent' do
selected_agent = selector.select_agent([member1])
expect(selected_agent).to eq(agent1)
end
end
context 'with new agents (no conversations)' do
it 'prioritizes agents with no conversations' do
# Agent1 and 2 have conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 is new with no conversations
selected_agent = selector.select_agent([member1, member2, member3])
expect(selected_agent).to eq(agent3)
end
end
end
end
@@ -1,119 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
# Assignment policy with rate limiting
let(:assignment_policy) do
create(:assignment_policy,
account: account,
enabled: true,
fair_distribution_limit: 5,
fair_distribution_window: 3600)
end
# Agent capacity policy
let(:agent_capacity_policy) do
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
end
# Agents with different capacity settings
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
before do
# Create inbox assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
# Set inbox capacity limit
create(:inbox_capacity_limit,
agent_capacity_policy: agent_capacity_policy,
inbox: inbox,
conversation_limit: 3)
# Assign capacity policy to specific agents
agent_with_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
agent_at_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
# Create inbox members
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
# Mock online status
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
agent_with_capacity.id.to_s => 'online',
agent_without_capacity.id.to_s => 'online',
agent_at_capacity.id.to_s => 'online'
})
# Enable assignment_v2 feature
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Create existing assignments for agent_at_capacity (at limit)
3.times do
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
end
end
describe 'capacity filtering' do
it 'excludes agents at capacity' do
# Get available agents respecting capacity
capacity_service = described_class.new
online_agents = inbox.available_agents
filtered_agents = online_agents.select do |inbox_member|
capacity_service.agent_has_capacity?(inbox_member.user, inbox)
end
available_users = filtered_agents.map(&:user)
expect(available_users).to include(agent_with_capacity)
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
end
it 'respects inbox-specific capacity limits' do
capacity_service = described_class.new
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
end
end
describe 'assignment with capacity' do
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
it 'assigns to agents with available capacity' do
# Create conversation before assignment
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent) do |agents|
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
end
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
end
it 'returns false when all agents are at capacity' do
# Fill up remaining agents
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
# agent_without_capacity has no limit, so should still be available
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
end
end
end
@@ -5,137 +5,100 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
context 'when V2 configs are missing' do
it 'raises a configuration error' do
expect do
create_stripe_customer_service.new(account: account).perform
end.to raise_error(StandardError, /V2 billing configuration is required/)
end
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
)
context 'with V2 billing' do
let(:cloud_plans_config) do
create(:installation_config,
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{
'name' => 'Startup',
'price_ids' => ['price_startup_123'],
'default_quantity' => 2
}
])
end
create_stripe_customer_service.new(account: account).perform
let(:hacker_plan_config) do
create(:installation_config,
name: 'STRIPE_HACKER_PLAN_ID',
value: 'bpp_hacker_123')
end
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 }] })
before do
# Setup configs
cloud_plans_config
hacker_plan_config
end
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
it 'creates a stripe customer and sets up V2 billing' 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
# Mock the plan feature manager
service = create_stripe_customer_service.new(account: account)
allow(service).to receive(:enable_plan_specific_features)
service.perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_random_number',
'stripe_billing_version' => 2,
'stripe_pricing_plan_id' => 'bpp_hacker_123',
'plan_name' => 'Hacker',
'subscribed_quantity' => 2
)
expect(service).to have_received(:enable_plan_specific_features).with('Hacker')
end
it 'does not create new customer when customer already exists with V2' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' })
allow(Stripe::Customer).to receive(:create)
# Stub the subscription check to return no subscriptions
subscriptions_response = OpenStruct.new(data: [])
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response)
# Mock the plan feature manager
service = create_stripe_customer_service.new(account: account)
allow(service).to receive(:enable_plan_specific_features)
service.perform
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => 'cus_existing_v2',
'stripe_billing_version' => 2,
'stripe_pricing_plan_id' => 'bpp_hacker_123',
'plan_name' => 'Hacker',
'subscribed_quantity' => 2
)
end
it 'skips setup when active subscription exists' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_v2' })
allow(Stripe::Customer).to receive(:create)
# Stub the subscription check to return active subscription
subscription_data = OpenStruct.new(id: 'sub_123')
subscriptions_response = OpenStruct.new(data: [subscription_data])
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response)
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' }
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
end
context 'when customer has active subscriptions' do
before do
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
end
expect(Stripe::Customer).not_to have_received(:create)
expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_v2')
end
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(:add_response_topup_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(:add_response_topup_credits).with(2000)
end
end
context 'when handling topup credit grant' do
it 'adds topup credits' 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_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(:add_response_topup_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
@@ -0,0 +1,31 @@
require 'rails_helper'
describe Enterprise::Billing::V2::CreditManagementService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
before do
account.update!(
custom_attributes: { 'captain_responses_usage' => 0 },
limits: { 'captain_responses_monthly' => 100, 'captain_responses_topup' => 50 }
)
end
describe '#sync_monthly_response_credits' do
it 'updates monthly credits' do
service.sync_monthly_response_credits(500)
expect(account.reload.limits['captain_responses_monthly']).to eq(500)
expect(account.limits['captain_responses']).to eq(550)
end
end
describe '#add_response_topup_credits' do
it 'adds topup credits' do
service.add_response_topup_credits(100)
expect(account.reload.limits['captain_responses_topup']).to eq(150)
expect(account.limits['captain_responses']).to eq(250)
end
end
end
@@ -0,0 +1,96 @@
require 'rails_helper'
describe Enterprise::Billing::V2::SubscriptionProvisioningService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account) }
let(:subscription_id) { 'bpps_subscription_123' }
let(:pricing_plan_id) { 'bpp_business_plan_123' }
before do
account.update!(custom_attributes: { 'pending_subscription_quantity' => 5 })
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
end
describe '#provision' do
let(:subscription_response) do
# Mock Stripe::V2::Billing::PricingPlanSubscription object
instance_double(
Stripe::V2::Billing::PricingPlanSubscription,
id: subscription_id,
pricing_plan: pricing_plan_id,
billing_cadence: 'bpc_cadence_123',
servicing_status: 'active'
)
end
before do
# Mock the retrieve_pricing_plan_subscription method directly
allow(service).to receive(:retrieve_pricing_plan_subscription).with(subscription_id).and_return(subscription_response)
end
it 'provisions subscription' do
result = service.provision(subscription_id: subscription_id)
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
expect(result[:quantity]).to eq(5)
end
end
describe '#refresh' do
let(:subscription_response) do
# Mock Stripe::V2::Billing::PricingPlanSubscription object
instance_double(
Stripe::V2::Billing::PricingPlanSubscription,
id: 'bpps_sub_456',
pricing_plan: pricing_plan_id,
billing_cadence: 'bpc_cadence_123',
servicing_status: 'active'
)
end
before do
account.update!(custom_attributes: { 'stripe_subscription_id' => 'bpps_sub_456' })
# Mock the retrieve_pricing_plan_subscription method directly
allow(service).to receive(:retrieve_pricing_plan_subscription).with('bpps_sub_456').and_return(subscription_response)
end
it 'refreshes subscription' do
result = service.refresh
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
end
end
describe '#cancel subscription' do
let(:subscription_response) do
# Mock Stripe::V2::Billing::PricingPlanSubscription object with canceled status
instance_double(
Stripe::V2::Billing::PricingPlanSubscription,
id: subscription_id,
pricing_plan: pricing_plan_id,
billing_cadence: 'bpc_cadence_123',
servicing_status: 'canceled'
)
end
let(:hacker_plan_id) { 'bpp_hacker_plan_123' }
before do
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
# Mock the retrieve_pricing_plan_subscription method directly
allow(service).to receive(:retrieve_pricing_plan_subscription).with(subscription_id).and_return(subscription_response)
end
it 'cancels subscription and resets to hacker plan' do
result = service.provision(subscription_id: subscription_id)
expect(result[:pricing_plan_id]).to be_nil
expect(result[:quantity]).to be_nil
expect(account.custom_attributes['plan_name']).to eq('Hacker')
expect(account.custom_attributes['stripe_subscription_id']).to be_nil
expect(account.custom_attributes['subscription_status']).to eq('canceled')
end
end
end
@@ -0,0 +1,35 @@
require 'rails_helper'
describe Enterprise::Billing::V2::TopupService do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Business' }) }
let(:service) { described_class.new(account: account) }
before do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(500).and_return(
credits: 500,
amount: 50.0,
currency: 'usd'
)
allow(Stripe::Customer).to receive(:retrieve).and_return(
OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: 'pm_123'), default_source: nil)
)
allow(Stripe::Invoice).to receive(:create).and_return(Stripe::Invoice.construct_from(id: 'in_123'))
allow(Stripe::InvoiceItem).to receive(:create)
allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(
Stripe::Invoice.construct_from(id: 'in_123', status: 'open')
)
allow(Stripe::Invoice).to receive(:pay).and_return(
Stripe::Invoice.construct_from(id: 'in_123', status: 'paid', total: 5000, hosted_invoice_url: 'https://invoice.stripe.com')
)
allow(Stripe::Billing::CreditGrant).to receive(:create).and_return({ 'id' => 'credgr_123' })
end
describe '#create_topup' do
it 'creates topup' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
end
end
end
@@ -0,0 +1,18 @@
require 'rails_helper'
describe Enterprise::Billing::V2::UsageReporterService do
let(:account) { create(:account, custom_attributes: { 'stripe_billing_version' => 2, 'stripe_customer_id' => 'cus_123' }) }
let(:service) { described_class.new(account: account) }
describe '#report' do
it 'posts usage to Stripe' do
allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(OpenStruct.new(identifier: 'me_123'))
result = service.report(5, 'ai_test')
expect(result[:success]).to be true
expect(result[:event_id]).to eq('me_123')
expect(Stripe::Billing::MeterEvent).to have_received(:create)
end
end
end
@@ -0,0 +1,63 @@
require 'rails_helper'
describe Enterprise::Billing::V2::WebhookHandlerService do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
let(:service) { described_class.new }
let(:pricing_plan_id) { 'bpp_plan_123' }
describe '#perform' do
let(:subscription_response) do
OpenStruct.new(
id: 'bpps_sub_123',
billing_cadence: 'cad_123',
pricing_plan: pricing_plan_id,
servicing_status: 'active'
)
end
let(:cadence_response) { OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) }
let(:event) do
event_double = double
allow(event_double).to receive(:blank?).and_return(false)
allow(event_double).to receive(:type).and_return('v2.billing.pricing_plan_subscription.servicing_activated')
allow(event_double).to receive(:related_object).and_return(OpenStruct.new(id: 'bpps_sub_123'))
event_double
end
before do
account # Ensure account is created
create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id)
# Mock the Stripe gem client methods
stripe_client_double = double
v2_double = double
billing_double = double
subscriptions_double = double
cadences_double = double
allow(service).to receive(:stripe_client).and_return(stripe_client_double)
allow(stripe_client_double).to receive(:v2).and_return(v2_double)
allow(v2_double).to receive(:billing).and_return(billing_double)
allow(billing_double).to receive(:pricing_plan_subscriptions).and_return(subscriptions_double)
allow(billing_double).to receive(:cadences).and_return(cadences_double)
allow(subscriptions_double).to receive(:retrieve).with('bpps_sub_123').and_return(subscription_response)
allow(cadences_double).to receive(:retrieve).with('cad_123').and_return(cadence_response)
end
it 'handles subscription activation' do
# Mock the subscription provisioning service
provisioning_service_double = instance_double(
Enterprise::Billing::V2::SubscriptionProvisioningService,
provision: { pricing_plan_id: pricing_plan_id, quantity: 1 }
)
allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new)
.with(account: account)
.and_return(provisioning_service_double)
result = service.perform(event: event)
expect(provisioning_service_double).to have_received(:provision).with(subscription_id: 'bpps_sub_123')
expect(result[:pricing_plan_id]).to eq(pricing_plan_id)
end
end
end
@@ -1,85 +0,0 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentJob, type: :job do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create(:inbox_member, inbox: inbox, user: agent)
end
describe '#perform' do
context 'when inbox exists' do
context 'when auto assignment is enabled' do
it 'calls the assignment service' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(5)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the assignment count' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(3)
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'uses custom bulk limit from environment' do
allow(ENV).to receive(:fetch).with('AUTO_ASSIGNMENT_BULK_LIMIT', 100).and_return('50')
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 50).and_return(2)
described_class.new.perform(inbox_id: inbox.id)
end
end
context 'when auto assignment is disabled' do
before { inbox.update!(enable_auto_assignment: false) }
it 'calls the service which handles the disabled state' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(0)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
context 'when inbox does not exist' do
it 'returns early without processing' do
expect(AutoAssignment::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: 999_999)
end
end
context 'when an error occurs' do
it 'logs the error and re-raises in test environment' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
expect do
described_class.new.perform(inbox_id: inbox.id)
end.to raise_error(StandardError, 'Something went wrong')
end
end
end
describe 'job configuration' do
it 'is queued in the default queue' do
expect(described_class.queue_name).to eq('default')
end
end
end
@@ -1,123 +0,0 @@
require 'rails_helper'
RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, inbox: inbox, user: agent)
end
describe '#perform' do
context 'when account has assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Account).to receive(:find_in_batches).and_yield([account])
end
context 'when inbox has auto_assignment_v2 enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(account).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
allow(inbox_relation).to receive(:find_in_batches).and_yield([inbox])
end
it 'queues assignment job for eligible inboxes' do
inbox_assignment_policy # ensure it exists
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
described_class.new.perform
end
it 'processes multiple accounts' do
inbox_assignment_policy # ensure it exists
account2 = create(:account)
inbox2 = create(:inbox, account: account2, enable_auto_assignment: true)
policy2 = create(:assignment_policy, account: account2)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation2 = instance_double(ActiveRecord::Relation)
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
allow(inbox_relation2).to receive(:joins).with(:assignment_policy).and_return(inbox_relation2)
allow(inbox_relation2).to receive(:find_in_batches).and_yield([inbox2])
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
described_class.new.perform
end
end
context 'when inbox does not have auto_assignment_v2 enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
end
it 'does not queue assignment job' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
described_class.new.perform
end
end
end
context 'when account does not have assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
allow(Account).to receive(:find_in_batches).and_yield([account])
end
it 'does not process the account' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
described_class.new.perform
end
end
context 'with batch processing' do
it 'processes accounts in batches' do
accounts = []
# Create multiple accounts
5.times do |_i|
acc = create(:account)
inb = create(:inbox, account: acc, enable_auto_assignment: true)
policy = create(:assignment_policy, account: acc)
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(acc).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
allow(inbox_relation).to receive(:find_in_batches).and_yield([inb])
accounts << acc
end
allow(Account).to receive(:find_in_batches) do |&block|
accounts.each { |acc| block.call([acc]) }
end
expect(Account).to receive(:find_in_batches).and_call_original
described_class.new.perform
end
end
end
describe 'job configuration' do
it 'is queued in the scheduled_jobs queue' do
expect(described_class.queue_name).to eq('scheduled_jobs')
end
end
end
+32 -3
View File
@@ -576,9 +576,38 @@ RSpec.describe Conversation do
expect(conversation.status).to eq('pending')
end
it 'returns conversation as open if campaign is present' do
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign))
expect(conversation.status).to eq('open')
context 'with campaigns' do
let(:user) { create(:user, account: bot_inbox.inbox.account) }
it 'returns conversation as open if campaign has a sender' do
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
expect(conversation.status).to eq('open')
end
it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
expect(conversation.status).to eq('pending')
end
end
context 'with campaigns in inbox without bot' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
it 'returns conversation as open if campaign has no sender but no bot is active' do
campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
conversation = create(:conversation, inbox: inbox, campaign: campaign)
expect(conversation.status).to eq('open')
end
it 'returns conversation as open if campaign has a sender' do
campaign = create(:campaign, inbox: inbox, account: account, sender: user)
conversation = create(:conversation, inbox: inbox, campaign: campaign)
expect(conversation.status).to eq('open')
end
end
end
@@ -1,310 +0,0 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentService do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:service) { described_class.new(inbox: inbox) }
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let(:conversation) { create(:conversation, inbox: inbox, assignee: nil) }
before do
# Enable assignment_v2 feature for the account
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
create(:inbox_member, inbox: inbox, user: agent)
end
describe '#perform_bulk_assignment' do
context 'when auto assignment is enabled' do
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
# Mock RoundRobinSelector to return the agent
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
# Mock RateLimiter to allow all assignments by default
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
allow(rate_limiter).to receive(:within_limit?).and_return(true)
allow(rate_limiter).to receive(:track_assignment)
end
it 'assigns conversations to available agents' do
# Create conversation and ensure it's unassigned
conv = create(:conversation, inbox: inbox, status: 'open')
conv.update!(assignee_id: nil)
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conv.reload.assignee).to eq(agent)
end
it 'returns 0 when no agents are online' do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(0)
expect(conversation.reload.assignee).to be_nil
end
it 'respects the limit parameter' do
3.times do
conv = create(:conversation, inbox: inbox, status: 'open')
conv.update!(assignee_id: nil)
end
assigned_count = service.perform_bulk_assignment(limit: 2)
expect(assigned_count).to eq(2)
expect(inbox.conversations.unassigned.count).to eq(1)
end
it 'only assigns open conversations' do
conversation # ensure it exists
conversation.update!(assignee_id: nil)
resolved_conversation = create(:conversation, inbox: inbox, status: 'resolved')
resolved_conversation.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 10)
expect(conversation.reload.assignee).to eq(agent)
expect(resolved_conversation.reload.assignee).to be_nil
end
it 'does not reassign already assigned conversations' do
conversation # ensure it exists
conversation.update!(assignee_id: nil)
assigned_conversation = create(:conversation, inbox: inbox, assignee: agent)
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
unassigned_conversation.update!(assignee_id: nil)
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(2) # conversation + unassigned_conversation
expect(assigned_conversation.reload.assignee).to eq(agent)
expect(unassigned_conversation.reload.assignee).to eq(agent)
end
it 'dispatches assignee changed event' do
conversation # ensure it exists
conversation.update!(assignee_id: nil)
# The conversation model also dispatches a conversation.updated event
allow(Rails.configuration.dispatcher).to receive(:dispatch)
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
Events::Types::ASSIGNEE_CHANGED,
anything,
hash_including(conversation: conversation, user: agent)
)
service.perform_bulk_assignment(limit: 1)
end
end
context 'when auto assignment is disabled' do
before { assignment_policy.update!(enabled: false) }
it 'returns 0 without processing' do
assigned_count = service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(0)
expect(conversation.reload.assignee).to be_nil
end
end
context 'with conversation priority' do
let(:rate_limiter) { instance_double(AutoAssignment::RateLimiter) }
before do
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ agent.id.to_s => 'online' })
# Mock RoundRobinSelector to return the agent
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
# Mock RateLimiter to allow all assignments by default
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(rate_limiter)
allow(rate_limiter).to receive(:within_limit?).and_return(true)
allow(rate_limiter).to receive(:track_assignment)
end
context 'when priority is longest_waiting' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({ 'conversation_priority' => 'longest_waiting' })
end
it 'assigns conversations with oldest last_activity_at first' do
old_conversation = create(:conversation,
inbox: inbox,
status: 'open',
created_at: 2.hours.ago,
last_activity_at: 2.hours.ago)
old_conversation.update!(assignee_id: nil)
new_conversation = create(:conversation,
inbox: inbox,
status: 'open',
created_at: 1.hour.ago,
last_activity_at: 1.hour.ago)
new_conversation.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).to eq(agent)
expect(new_conversation.reload.assignee).to be_nil
end
end
context 'when priority is default' do
it 'assigns conversations by created_at' do
old_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 2.hours.ago)
old_conversation.update!(assignee_id: nil)
new_conversation = create(:conversation, inbox: inbox, status: 'open', created_at: 1.hour.ago)
new_conversation.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 1)
expect(old_conversation.reload.assignee).to eq(agent)
expect(new_conversation.reload.assignee).to be_nil
end
end
end
context 'with fair distribution' do
before do
create(:inbox_member, inbox: inbox, user: agent2)
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
agent.id.to_s => 'online',
agent2.id.to_s => 'online'
})
end
context 'when fair distribution is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 2,
'fair_distribution_window' => 3600
})
end
it 'respects the assignment limit per agent' do
# Mock RoundRobinSelector to select agent2
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent2)
# Mock agent1 at limit, agent2 not at limit
agent1_limiter = instance_double(AutoAssignment::RateLimiter)
agent2_limiter = instance_double(AutoAssignment::RateLimiter)
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent).and_return(agent1_limiter)
allow(AutoAssignment::RateLimiter).to receive(:new).with(inbox: inbox, agent: agent2).and_return(agent2_limiter)
allow(agent1_limiter).to receive(:within_limit?).and_return(false)
allow(agent2_limiter).to receive(:within_limit?).and_return(true)
allow(agent2_limiter).to receive(:track_assignment)
unassigned_conversation = create(:conversation, inbox: inbox, status: 'open')
unassigned_conversation.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 1)
expect(unassigned_conversation.reload.assignee).to eq(agent2)
end
it 'tracks assignments in Redis' do
conversation # ensure it exists
conversation.update!(assignee_id: nil)
# Mock RoundRobinSelector
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
limiter = instance_double(AutoAssignment::RateLimiter)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
allow(limiter).to receive(:within_limit?).and_return(true)
expect(limiter).to receive(:track_assignment)
service.perform_bulk_assignment(limit: 1)
end
it 'allows assignments after window expires' do
# Mock RoundRobinSelector
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent, agent2)
# Mock RateLimiter to allow all
limiter = instance_double(AutoAssignment::RateLimiter)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
allow(limiter).to receive(:within_limit?).and_return(true)
allow(limiter).to receive(:track_assignment)
# Simulate time passing for rate limit window
freeze_time do
2.times do
conversation_new = create(:conversation, inbox: inbox, status: 'open')
conversation_new.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 1)
expect(conversation_new.reload.assignee).not_to be_nil
end
end
# Move forward past the window
travel_to(2.hours.from_now) do
new_conversation = create(:conversation, inbox: inbox, status: 'open')
new_conversation.update!(assignee_id: nil)
service.perform_bulk_assignment(limit: 1)
expect(new_conversation.reload.assignee).not_to be_nil
end
end
end
context 'when fair distribution is disabled' do
it 'assigns without rate limiting' do
5.times do
conv = create(:conversation, inbox: inbox, status: 'open')
conv.update!(assignee_id: nil)
end
# Mock RoundRobinSelector
round_robin_selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(round_robin_selector)
allow(round_robin_selector).to receive(:select_agent).and_return(agent)
# Mock RateLimiter to allow all
limiter = instance_double(AutoAssignment::RateLimiter)
allow(AutoAssignment::RateLimiter).to receive(:new).and_return(limiter)
allow(limiter).to receive(:within_limit?).and_return(true)
allow(limiter).to receive(:track_assignment)
assigned_count = service.perform_bulk_assignment(limit: 5)
expect(assigned_count).to eq(5)
end
end
context 'with round robin assignment' do
it 'distributes conversations evenly among agents' do
conversations = Array.new(4) { create(:conversation, inbox: inbox, assignee: nil) }
service.perform_bulk_assignment(limit: 4)
agent1_count = conversations.count { |c| c.reload.assignee == agent }
agent2_count = conversations.count { |c| c.reload.assignee == agent2 }
# Should be distributed evenly (2 each) or close to even (3 and 1)
expect([agent1_count, agent2_count].sort).to eq([2, 2]).or(eq([1, 3]))
end
end
end
end
end
@@ -1,139 +0,0 @@
require 'rails_helper'
RSpec.describe AutoAssignment::RateLimiter do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, inbox: inbox) }
let(:rate_limiter) { described_class.new(inbox: inbox, agent: agent) }
describe '#within_limit?' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'returns true' do
expect(rate_limiter.within_limit?).to be true
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'returns true when under the limit' do
allow(rate_limiter).to receive(:current_count).and_return(3)
expect(rate_limiter.within_limit?).to be true
end
it 'returns false when at or over the limit' do
allow(rate_limiter).to receive(:current_count).and_return(5)
expect(rate_limiter.within_limit?).to be false
end
end
end
describe '#track_assignment' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'does not track the assignment' do
expect(Redis::Alfred).not_to receive(:set)
rate_limiter.track_assignment(conversation)
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'creates a Redis key with correct expiry' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 3600
)
rate_limiter.track_assignment(conversation)
end
end
end
describe '#current_count' do
context 'when rate limiting is not enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({})
end
it 'returns 0' do
expect(rate_limiter.current_count).to eq(0)
end
end
context 'when rate limiting is enabled' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 5,
'fair_distribution_window' => 3600
})
end
it 'counts matching Redis keys' do
pattern = format(Redis::RedisKeys::ASSIGNMENT_KEY_PATTERN, inbox_id: inbox.id, agent_id: agent.id)
allow(Redis::Alfred).to receive(:keys_count).with(pattern).and_return(3)
expect(rate_limiter.current_count).to eq(3)
end
end
end
describe 'configuration' do
context 'with custom window' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 10,
'fair_distribution_window' => 7200
})
end
it 'uses the custom window value' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 7200
)
rate_limiter.track_assignment(conversation)
end
end
context 'without custom window' do
before do
allow(inbox).to receive(:auto_assignment_config).and_return({
'fair_distribution_limit' => 10
})
end
it 'uses the default window value of 3600' do
expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id)
expect(Redis::Alfred).to receive(:set).with(
expected_key,
conversation.id.to_s,
ex: 3600
)
rate_limiter.track_assignment(conversation)
end
end
end
end
@@ -1,78 +0,0 @@
require 'rails_helper'
RSpec.describe AutoAssignment::RoundRobinSelector do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:selector) { described_class.new(inbox: inbox) }
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
let(:round_robin_service) { instance_double(AutoAssignment::InboxRoundRobinService) }
let(:member1) do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
allow(round_robin_service).to receive(:add_agent_to_queue)
create(:inbox_member, inbox: inbox, user: agent1)
end
let(:member2) do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
allow(round_robin_service).to receive(:add_agent_to_queue)
create(:inbox_member, inbox: inbox, user: agent2)
end
let(:member3) do
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
allow(round_robin_service).to receive(:add_agent_to_queue)
create(:inbox_member, inbox: inbox, user: agent3)
end
before do
# Mock the round robin service to avoid Redis calls
allow(AutoAssignment::InboxRoundRobinService).to receive(:new).and_return(round_robin_service)
allow(round_robin_service).to receive(:add_agent_to_queue)
allow(round_robin_service).to receive(:reset_queue)
allow(round_robin_service).to receive(:validate_queue?).and_return(true)
end
describe '#select_agent' do
context 'when agents are available' do
let(:available_agents) { [member1, member2, member3] }
it 'returns an agent from the available list' do
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
selected_agent = selector.select_agent(available_agents)
expect(selected_agent).not_to be_nil
expect([agent1, agent2, agent3]).to include(selected_agent)
end
it 'uses round robin service for selection' do
expect(round_robin_service).to receive(:available_agent).with(
allowed_agent_ids: [agent1.id.to_s, agent2.id.to_s, agent3.id.to_s]
).and_return(agent1)
selected_agent = selector.select_agent(available_agents)
expect(selected_agent).to eq(agent1)
end
end
context 'when no agents are available' do
it 'returns nil' do
selected_agent = selector.select_agent([])
expect(selected_agent).to be_nil
end
end
context 'when one agent is available' do
it 'returns that agent' do
allow(round_robin_service).to receive(:available_agent).and_return(agent1)
selected_agent = selector.select_agent([member1])
expect(selected_agent).to eq(agent1)
end
end
end
end