Compare commits

..
20 changed files with 465 additions and 1015 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe', '~> 18.0'
gem 'stripe'
## - helper gems --##
## to populate db with sample data
+2 -2
View File
@@ -928,7 +928,7 @@ GEM
squasher (0.7.2)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.4.0)
@@ -1139,7 +1139,7 @@ DEPENDENCIES
spring-watcher-listen
squasher
stackprof
stripe (~> 18.0)
stripe
telephone_number
test-prof
tidewave
@@ -27,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -23,10 +23,6 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
}
export default new EnterpriseAccountAPI();
@@ -1,452 +1,462 @@
<script>
import { ref, provide } from 'vue';
// composable
<script setup>
import {
ref,
computed,
watch,
onMounted,
onUnmounted,
nextTick,
provide,
getCurrentInstance,
} from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
useInbox,
INBOX_FEATURES,
INBOX_FEATURE_MAP,
} from 'dashboard/composables/useInbox';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
// components
import ReplyBox from './ReplyBox.vue';
import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
// utils
import { emitter } from 'shared/helpers/mitt';
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { LocalStorage } from 'shared/helpers/localStorage';
import {
filterDuplicateSourceMessages,
getReadMessages,
getUnreadMessages,
} from 'dashboard/helper/conversationHelper';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
MessageList,
ReplyBox,
Banner,
ConversationLabelSuggestion,
Spinner,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const store = useStore();
const route = useRoute();
const { t } = useI18n();
const instance = getCurrentInstance();
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
useKeyboardEvents(keyboardEvents);
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const isLoadingPrevious = ref(true);
const heightBeforeLoad = ref(null);
const scrollTopBeforeLoad = ref(null);
const conversationPanel = ref(null);
const hasUserScrolled = ref(false);
const isProgrammaticScroll = ref(false);
const messageSentSinceOpened = ref(false);
const labelSuggestions = ref([]);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
conversationPanelRef,
};
},
data() {
return {
isLoadingPrevious: true,
heightBeforeLoad: null,
conversationPanel: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
typingUsersList() {
const userList = this.$store.getters[
'conversationTypingStatus/getUserList'
](this.currentChat.id);
return userList;
},
isAnyoneTyping() {
const userList = this.typingUsersList;
return userList.length !== 0;
},
typingUserNames() {
const userList = this.typingUsersList;
if (this.isAnyoneTyping) {
const [i18nKey, params] = getTypingUsersText(userList);
return this.$t(i18nKey, params);
}
return '';
},
getMessages() {
const messages = this.currentChat.messages || [];
if (this.isAWhatsAppChannel) {
return filterDuplicateSourceMessages(messages);
}
return messages;
},
readMessages() {
return getReadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
unReadMessages() {
return getUnreadMessages(
this.getMessages,
this.currentChat.agent_last_seen_at
);
},
shouldShowSpinner() {
return (
(this.currentChat && this.currentChat.dataFetched === undefined) ||
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
// Check there is a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
const { additional_attributes: additionalAttributes = {} } = this.inbox;
const instagramInbox =
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
instagramId
);
return (
this.inbox.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (this.isAPIInbox) {
const { additional_attributes: additionalAttributes = {} } = this.inbox;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return this.$t('CONVERSATION.CANNOT_REPLY');
},
replyWindowLink() {
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
return REPLY_POLICY.FACEBOOK;
}
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
},
replyWindowLinkText() {
if (
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAnInstagramChannel
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
},
unreadMessageCount() {
return this.currentChat.unread_count || 0;
},
unreadMessageLabel() {
const count =
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
const label =
this.unreadMessageCount > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${this.$t(label)}`;
},
inboxSupportsReplyTo() {
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
},
},
created() {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
this.currentAccountId,
this.currentChat.id
);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams();
this.conversationPanel.addEventListener('scroll', this.handleScroll);
this.$nextTick(() => this.scrollToBottom());
this.isLoadingPrevious = false;
},
removeScrollListener() {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions =
this.conversationPanel.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages =
this.conversationPanel.querySelectorAll('.message--unread');
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
relevantMessages
);
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.currentChat.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
if (
scrollTop < 100 &&
!this.isLoadingPrevious &&
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.currentChat.messages[0].id,
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
async handleMessageRetry(message) {
if (!message) return;
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
provide('contextMenuElementTarget', conversationPanelRef);
const currentChat = useMapGetter('getSelectedChat');
const currentUserId = useMapGetter('getCurrentUserID');
const listLoadingStatus = useMapGetter('getAllMessagesLoaded');
const currentAccountId = useMapGetter('getCurrentAccountId');
const instagramInboxByIdGetter = useMapGetter(
'inboxes/getInstagramInboxByInstagramId'
);
const inboxGetter = useMapGetter('inboxes/getInbox');
const typingUsersListGetter = useMapGetter(
'conversationTypingStatus/getUserList'
);
const isOpen = computed(() => {
return currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
});
const shouldShowLabelSuggestions = computed(() => {
return (
isOpen.value &&
isEnterprise &&
isAIIntegrationEnabled.value &&
!messageSentSinceOpened.value
);
});
const inboxId = computed(() => {
return currentChat.value?.inbox_id;
});
const inbox = computed(() => {
return inboxGetter.value(inboxId.value);
});
const {
isAPIInbox,
isAFacebookInbox,
isAWhatsAppChannel,
isAWhatsAppCloudChannel,
isAnInstagramChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
} = useInbox();
const inboxHasFeature = feature => {
const channelType = inbox.value?.channel_type;
return INBOX_FEATURE_MAP[feature]?.includes(channelType) ?? false;
};
const typingUsersList = computed(() => {
const userList = typingUsersListGetter.value(currentChat.value.id);
return userList;
});
const isAnyoneTyping = computed(() => {
const userList = typingUsersList.value;
return userList.length !== 0;
});
const typingUserNames = computed(() => {
const userList = typingUsersList.value;
if (isAnyoneTyping.value) {
const [i18nKey, params] = getTypingUsersText(userList);
return t(i18nKey, params);
}
return '';
});
const getMessages = computed(() => {
const messages = currentChat.value.messages || [];
if (isAWhatsAppChannel.value) {
return filterDuplicateSourceMessages(messages);
}
return messages;
});
const unReadMessages = computed(() => {
return getUnreadMessages(
getMessages.value,
currentChat.value.agent_last_seen_at
);
});
const shouldShowSpinner = computed(() => {
return (
(currentChat.value && currentChat.value.dataFetched === undefined) ||
(!listLoadingStatus.value && isLoadingPrevious.value)
);
});
// Check there is a instagram inbox exists with the same instagram_id
const hasDuplicateInstagramInbox = computed(() => {
const instagramId = inbox.value.instagram_id;
const { additional_attributes: additionalAttributes = {} } = inbox.value;
const instagramInbox = instagramInboxByIdGetter.value(instagramId);
return (
inbox.value.channel_type === INBOX_TYPES.FB &&
additionalAttributes.type === 'instagram_direct_message' &&
instagramInbox
);
});
const replyWindowBannerMessage = computed(() => {
if (isAWhatsAppChannel.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
}
if (isAPIInbox.value) {
const { additional_attributes: additionalAttributes = {} } = inbox.value;
if (additionalAttributes) {
const {
agent_reply_time_window_message: agentReplyTimeWindowMessage,
agent_reply_time_window: agentReplyTimeWindow,
} = additionalAttributes;
return (
agentReplyTimeWindowMessage ||
t('CONVERSATION.API_HOURS_WINDOW', {
hours: agentReplyTimeWindow,
})
);
}
return '';
}
return t('CONVERSATION.CANNOT_REPLY');
});
const replyWindowLink = computed(() => {
if (isAFacebookInbox.value || isAnInstagramChannel.value) {
return REPLY_POLICY.FACEBOOK;
}
if (isAWhatsAppCloudChannel.value) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (!isAPIInbox.value) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
return '';
});
const replyWindowLinkText = computed(() => {
if (
isAWhatsAppChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value
) {
return t('CONVERSATION.24_HOURS_WINDOW');
}
if (!isAPIInbox.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
return '';
});
const unreadMessageCount = computed(() => {
return currentChat.value.unread_count || 0;
});
const unreadMessageLabel = computed(() => {
const count = unreadMessageCount.value > 9 ? '9+' : unreadMessageCount.value;
const label =
unreadMessageCount.value > 1
? 'CONVERSATION.UNREAD_MESSAGES'
: 'CONVERSATION.UNREAD_MESSAGE';
return `${count} ${t(label)}`;
});
const inboxSupportsReplyTo = computed(() => {
const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!is360DialogWhatsAppChannel.value;
return { incoming, outgoing };
});
const isLabelSuggestionDismissed = () => {
return LocalStorage.getFlag(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
currentAccountId.value,
currentChat.value.id
);
};
const fetchAllAttachmentsFromCurrentChat = () => {
store.dispatch('fetchAllAttachments', currentChat.value.id);
};
const makeMessagesRead = () => {
store.dispatch('markMessagesRead', { id: currentChat.value.id });
};
const setScrollParams = () => {
heightBeforeLoad.value = conversationPanel.value.scrollHeight;
scrollTopBeforeLoad.value = conversationPanel.value.scrollTop;
};
const scrollToBottom = () => {
isProgrammaticScroll.value = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestionsEl =
conversationPanel.value.querySelector('.label-suggestion');
// if there are unread messages, scroll to the first unread message
if (unreadMessageCount.value > 0) {
// capturing only the unread messages
relevantMessages =
conversationPanel.value.querySelectorAll('.message--unread');
} else if (labelSuggestionsEl) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestionsEl];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
conversationPanel.value.querySelectorAll('.message--read')
).slice(-1);
}
conversationPanel.value.scrollTop = calculateScrollTop(
conversationPanel.value.scrollHeight,
instance.proxy.$el.scrollHeight,
relevantMessages
);
};
const fetchPreviousMessages = async (scrollTop = 0) => {
setScrollParams();
const shouldLoadMoreMessages =
currentChat.value.dataFetched === true &&
!listLoadingStatus.value &&
!isLoadingPrevious.value;
if (scrollTop < 100 && !isLoadingPrevious.value && shouldLoadMoreMessages) {
isLoadingPrevious.value = true;
try {
await store.dispatch('fetchPreviousMessages', {
conversationId: currentChat.value.id,
before: currentChat.value.messages[0].id,
});
const heightDifference =
conversationPanel.value.scrollHeight - heightBeforeLoad.value;
conversationPanel.value.scrollTop =
scrollTopBeforeLoad.value + heightDifference;
setScrollParams();
} catch (error) {
// Ignore Error
} finally {
isLoadingPrevious.value = false;
}
}
};
const onScrollToMessage = ({ messageId = '' } = {}) => {
nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
isProgrammaticScroll.value = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
fetchPreviousMessages();
} else {
scrollToBottom();
}
});
makeMessagesRead();
};
const handleScroll = e => {
if (isProgrammaticScroll.value) {
// Reset the flag
isProgrammaticScroll.value = false;
hasUserScrolled.value = false;
} else {
hasUserScrolled.value = true;
}
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
fetchPreviousMessages(e.target.scrollTop);
};
const addScrollListener = () => {
conversationPanel.value = instance.proxy.$el.querySelector(
'.conversation-panel'
);
setScrollParams();
conversationPanel.value.addEventListener('scroll', handleScroll);
nextTick(() => scrollToBottom());
isLoadingPrevious.value = false;
};
const removeScrollListener = () => {
conversationPanel.value.removeEventListener('scroll', handleScroll);
};
const removeBusListeners = () => {
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
};
const fetchSuggestions = async () => {
// start empty, this ensures that the label suggestions are not shown
labelSuggestions.value = [];
if (isLabelSuggestionDismissed()) {
return;
}
if (!isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = currentChat.value?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await fetchIntegrationsIfRequired();
if (!isLabelSuggestionFeatureEnabled.value) {
return;
}
labelSuggestions.value = await fetchLabelSuggestions({
conversationId: currentChat.value.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !hasUserScrolled.value) {
scrollToBottom();
}
});
};
const handleMessageRetry = async message => {
if (!message) return;
const payload = useSnakeCase(message);
await store.dispatch('sendMessageWithData', payload);
};
watch(currentChat, (newChat, oldChat) => {
if (newChat.id === oldChat.id) {
return;
}
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
messageSentSinceOpened.value = false;
});
onMounted(() => {
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
messageSentSinceOpened.value = true;
});
addScrollListener();
fetchAllAttachmentsFromCurrentChat();
fetchSuggestions();
});
onUnmounted(() => {
removeBusListeners();
removeScrollListener();
});
</script>
<template>
@@ -463,7 +473,7 @@ export default {
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
:banner-message="t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<MessageList
ref="conversationPanelRef"
@@ -1,5 +1,5 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@@ -9,7 +9,6 @@ export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -35,8 +34,6 @@ export function useCaptain() {
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
@@ -49,6 +46,5 @@ export function useCaptain() {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
};
}
@@ -399,31 +399,15 @@
"DESCRIPTION": "Manage usage and credits for Captain AI.",
"BUTTON_TXT": "Buy more credits",
"DOCUMENTS": "Documents",
"RESPONSES": "Credits",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
"REFRESH_CREDITS": "Refresh"
"RESPONSES": "Responses",
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
},
"CHAT_WITH_US": {
"TITLE": "Need help?",
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
"BUTTON_TXT": "Chat with us"
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
"TOPUP": {
"BUY_CREDITS": "Buy more credits",
"MODAL_TITLE": "Buy AI Credits",
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
"CREDITS": "CREDITS",
"ONE_TIME": "one-time",
"POPULAR": "Most Popular",
"NOTE_TITLE": "Note:",
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
"CANCEL": "Cancel",
"PURCHASE": "Purchase Credits",
"LOADING": "Loading options...",
"FETCH_ERROR": "Failed to load credit options. Please try again.",
"PURCHASE_ERROR": "Failed to process purchase. Please try again."
}
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
@@ -11,7 +11,6 @@ import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -24,7 +23,6 @@ const {
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
} = useCaptain();
const uiFlags = useMapGetter('accounts/getUIFlags');
@@ -34,7 +32,6 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
@@ -48,11 +45,6 @@ const planName = computed(() => {
return customAttributes.value.plan_name;
});
const canPurchaseCredits = computed(() => {
const plan = planName.value?.toLowerCase();
return plan && plan !== 'hacker';
});
/**
* Computed property for subscribed quantity
* @returns {number|undefined}
@@ -79,9 +71,8 @@ const hasABillingPlan = computed(() => {
const fetchAccountDetails = async () => {
if (!hasABillingPlan.value) {
await store.dispatch('accounts/subscription');
fetchLimits();
}
// Always fetch limits for billing page to show credit usage
fetchLimits();
};
const handleBillingPageLogic = async () => {
@@ -128,10 +119,6 @@ const onToggleChatWindow = () => {
}
};
const openPurchaseCreditsModal = () => {
purchaseCreditsModalRef.value?.open();
};
onMounted(handleBillingPageLogic);
</script>
@@ -191,27 +178,9 @@ onMounted(handleBillingPageLogic);
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
>
<template #action>
<div class="flex gap-2">
<ButtonV4
sm
flushed
slate
icon="i-lucide-refresh-cw"
:is-loading="isFetchingLimits"
@click="fetchLimits"
>
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
</ButtonV4>
<ButtonV4
v-if="canPurchaseCredits"
sm
solid
blue
@click="openPurchaseCreditsModal"
>
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
</ButtonV4>
</div>
<ButtonV4 sm faded slate disabled>
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
</ButtonV4>
</template>
<div v-if="captainLimits && responseLimits" class="px-5">
<BillingMeter
@@ -254,7 +223,6 @@ onMounted(handleBillingPageLogic);
</ButtonV4>
</BillingHeader>
</section>
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
</template>
</SettingsLayout>
</template>
@@ -1,101 +0,0 @@
<script setup>
defineProps({
credits: {
type: Number,
required: true,
},
amount: {
type: Number,
required: true,
},
currency: {
type: String,
default: 'usd',
},
isSelected: {
type: Boolean,
default: false,
},
isPopular: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
});
const emit = defineEmits(['select']);
const formatCredits = credits => {
return credits.toLocaleString();
};
const formatAmount = (amount, currency) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: 0,
}).format(amount);
};
</script>
<template>
<label
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
:class="[
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
]"
>
<input
type="radio"
:name="name"
:value="credits"
:checked="isSelected"
class="sr-only"
@change="emit('select')"
/>
<span
v-if="isPopular"
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
:class="
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
"
>
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
</span>
<div
v-if="isSelected"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
{{ formatCredits(credits) }}
</span>
<span
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
>
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
</span>
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
{{ formatAmount(amount, currency) }}
<span class="text-sm text-n-slate-11 ml-0.5">{{
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
}}</span>
</span>
</label>
</template>
@@ -1,128 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CreditPackageCard from './CreditPackageCard.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const emit = defineEmits(['close']);
const { t } = useI18n();
const TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12000, amount: 200.0, currency: 'usd' },
];
const POPULAR_CREDITS_AMOUNT = 6000;
const dialogRef = ref(null);
const selectedCredits = ref(null);
const isLoading = ref(false);
const selectedOption = computed(() => {
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
});
const handlePackageSelect = credits => {
selectedCredits.value = credits;
};
const handlePurchase = async () => {
if (!selectedOption.value) return;
isLoading.value = true;
try {
const response = await EnterpriseAccountAPI.createTopupCheckout(
selectedOption.value.credits
);
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url;
}
} catch (error) {
const errorMessage =
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
const handleClose = () => {
emit('close');
};
const open = () => {
// Pre-select the most popular option
const popularOption = TOPUP_OPTIONS.find(
o => o.credits === POPULAR_CREDITS_AMOUNT
);
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')"
:description="$t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')"
width="xl"
:show-confirm-button="false"
:show-cancel-button="false"
@close="handleClose"
>
<div class="grid grid-cols-2 gap-4">
<CreditPackageCard
v-for="option in TOPUP_OPTIONS"
:key="option.credits"
name="credit-package"
:credits="option.credits"
:amount="option.amount"
:currency="option.currency"
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
:is-selected="selectedCredits === option.credits"
@select="handlePackageSelect(option.credits)"
/>
</div>
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
<p class="text-sm text-n-slate-11">
<span class="font-semibold text-n-slate-12">{{
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
}}</span>
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
</p>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
class="w-full"
@click="close"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
class="w-full"
:disabled="!selectedCredits"
:is-loading="isLoading"
@click="handlePurchase"
/>
</div>
</template>
</Dialog>
</template>
@@ -18,7 +18,6 @@ const state = {
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingLimits: false,
},
};
@@ -142,14 +141,11 @@ export const actions = {
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
+1 -1
View File
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9, :ig_post => 10 }
:contact => 8, :ig_reel => 9 }
def push_event_data
return unless file_type
-4
View File
@@ -30,8 +30,4 @@ class AccountPolicy < ApplicationPolicy
def toggle_deletion?
@account_user.administrator?
end
def topup_checkout?
@account_user.administrator?
end
end
-5
View File
@@ -122,11 +122,6 @@ en:
invalid_token: Invalid or expired MFA token
invalid_credentials: Invalid credentials or verification code
feature_unavailable: MFA feature is not available. Please configure encryption keys.
topup:
credits_required: Credits amount is required
invalid_credits: Invalid credits amount
invalid_option: Invalid topup option
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
profile:
mfa:
enabled: MFA enabled successfully
-2
View File
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings'
resource :widget, only: [:show]
namespace :survey do
@@ -439,7 +438,6 @@ Rails.application.routes.draw do
post :subscription
get :limits
post :toggle_deletion
post :topup_checkout
end
end
end
@@ -55,17 +55,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
def topup_checkout
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
redirect_url = service.create_checkout_session(credits: params[:credits].to_i)
render json: { redirect_url: redirect_url }
rescue Enterprise::Billing::TopupCheckoutService::Error => e
Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}")
render_could_not_create_error(e.message)
end
private
def check_cloud_env
@@ -1,6 +1,5 @@
class Enterprise::Billing::HandleStripeEventService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
@@ -34,8 +33,6 @@ class Enterprise::Billing::HandleStripeEventService
process_subscription_updated
when 'customer.subscription.deleted'
process_subscription_deleted
when 'checkout.session.completed'
process_checkout_session_completed
else
Rails.logger.debug { "Unhandled event type: #{event.type}" }
end
@@ -49,43 +46,23 @@ class Enterprise::Billing::HandleStripeEventService
# skipping self hosted plan events
return if plan.blank? || account.blank?
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
if billing_period_renewed?
ActiveRecord::Base.transaction do
handle_subscription_credits(plan, previous_usage)
account.reset_response_usage
end
elsif plan_changed?
handle_plan_change_credits(plan, previous_usage)
end
end
def capture_previous_usage
{ responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: current_plan_credits[:responses] }
end
def current_plan_credits
plan_name = account.custom_attributes['plan_name']
return { responses: 0, documents: 0 } if plan_name.blank?
get_plan_credits(plan_name)
reset_captain_usage
end
def update_account_attributes(subscription, plan)
# https://stripe.com/docs/api/subscriptions/object
account.update(
custom_attributes: account.custom_attributes.merge(
'stripe_customer_id' => subscription.customer,
'stripe_price_id' => subscription['plan']['id'],
'stripe_product_id' => subscription['plan']['product'],
'plan_name' => plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
)
custom_attributes: {
stripe_customer_id: subscription.customer,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: plan['name'],
subscribed_quantity: subscription['quantity'],
subscription_status: subscription['status'],
subscription_ends_on: Time.zone.at(subscription['current_period_end'])
}
)
end
@@ -96,31 +73,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def process_checkout_session_completed
session = @event.data.object
metadata = session.metadata
# Only process topup checkout sessions
return unless metadata.present? && metadata['topup'] == 'true'
topup_account = Account.find_by(id: metadata['account_id'])
return if topup_account.blank?
credits = metadata['credits'].to_i
amount_cents = metadata['amount_cents'].to_i
currency = metadata['currency'] || 'usd'
Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents")
Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill(
credits: credits,
amount_cents: amount_cents,
currency: currency
)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: topup_account).capture_exception
raise
end
def update_plan_features
if default_plan?
disable_all_premium_features
@@ -149,47 +101,28 @@ class Enterprise::Billing::HandleStripeEventService
enable_plan_specific_features
end
def handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
new_plan_credits = get_plan_credits(plan['name'])[:responses]
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def handle_plan_change_credits(new_plan, previous_usage)
current_limits = account.limits || {}
current_credits = current_limits['captain_responses'].to_i
previous_plan_credits = previous_usage[:monthly]
new_plan_credits = get_plan_credits(new_plan['name'])[:responses]
updated_credits = current_credits - previous_plan_credits + new_plan_credits
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
end
def get_plan_credits(plan_name)
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
config = JSON.parse(config) if config.is_a?(String)
config[plan_name.downcase]&.symbolize_keys
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' then account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Startups'
# Startups plan gets the basic features
account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
# Business plan gets Startups features + Business features
account.enable_features(*STARTUP_PLAN_FEATURES)
account.enable_features(*BUSINESS_PLAN_FEATURES)
when 'Enterprise'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
# 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
@@ -197,25 +130,6 @@ class Enterprise::Billing::HandleStripeEventService
@subscription ||= @event.data.object
end
def previous_attributes
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
end
def plan_changed?
return false if previous_attributes['plan'].blank?
previous_plan_id = previous_attributes.dig('plan', 'id')
current_plan_id = subscription['plan']['id']
previous_plan_id != current_plan_id
end
def billing_period_renewed?
return false if previous_attributes['current_period_start'].blank?
previous_attributes['current_period_start'] != subscription['current_period_start']
end
def account
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
end
@@ -1,99 +0,0 @@
class Enterprise::Billing::TopupCheckoutService
include BillingHelper
include Rails.application.routes.url_helpers
class Error < StandardError; end
TOPUP_OPTIONS = [
{ credits: 1000, amount: 20.0, currency: 'usd' },
{ credits: 2500, amount: 50.0, currency: 'usd' },
{ credits: 6000, amount: 100.0, currency: 'usd' },
{ credits: 12_000, amount: 200.0, currency: 'usd' }
].freeze
pattr_initialize [:account!]
def create_checkout_session(credits:)
topup_option = validate_and_find_topup_option(credits)
session = create_stripe_session(topup_option, credits)
session.url
end
private
def validate_and_find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
topup_option = find_topup_option(credits)
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
topup_option
end
def create_stripe_session(topup_option, credits)
Stripe::Checkout::Session.create(
customer: stripe_customer_id,
mode: 'payment',
line_items: [build_line_item(topup_option, credits)],
success_url: success_url,
cancel_url: cancel_url,
metadata: session_metadata(credits, topup_option),
payment_method_types: ['card'],
# Show saved payment methods and allow saving new ones
saved_payment_method_options: {
payment_method_save: 'enabled',
allow_redisplay_filters: %w[always limited]
},
# Create invoice for this payment so it appears in customer portal
invoice_creation: build_invoice_creation_data(credits, topup_option)
)
end
def build_invoice_creation_data(credits, topup_option)
{
enabled: true,
invoice_data: {
description: "AI Credits Topup: #{credits} credits",
metadata: session_metadata(credits, topup_option)
}
}
end
def build_line_item(topup_option, credits)
{
price_data: {
currency: topup_option[:currency],
unit_amount: (topup_option[:amount] * 100).to_i,
product_data: { name: "AI Credits Topup: #{credits} credits" }
},
quantity: 1
}
end
def session_metadata(credits, topup_option)
{
account_id: account.id.to_s,
credits: credits.to_s,
amount_cents: (topup_option[:amount] * 100).to_s,
currency: topup_option[:currency],
topup: 'true'
}
end
def success_url
app_account_billing_settings_url(account_id: account.id, topup: 'success')
end
def cancel_url
app_account_billing_settings_url(account_id: account.id)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
def find_topup_option(credits)
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
end
end
@@ -1,49 +0,0 @@
class Enterprise::Billing::TopupFulfillmentService
pattr_initialize [:account!]
def fulfill(credits:, amount_cents:, currency:)
account.with_lock do
create_stripe_credit_grant(credits, amount_cents, currency)
update_account_credits(credits)
end
end
private
def create_stripe_credit_grant(credits, amount_cents, currency)
Stripe::Billing::CreditGrant.create(
customer: stripe_customer_id,
name: "Topup: #{credits} credits",
amount: {
type: 'monetary',
monetary: { currency: currency, value: amount_cents }
},
applicability_config: {
scope: { price_type: 'metered' }
},
category: 'paid',
expires_at: 6.months.from_now.to_i,
metadata: {
account_id: account.id.to_s,
source: 'topup',
credits: credits.to_s
}
)
end
def update_account_credits(credits)
current_limits = account.limits || {}
current_total = current_limits['captain_responses'].to_i
new_total = current_total + credits
account.update!(
limits: current_limits.merge(
'captain_responses' => new_total
)
)
end
def stripe_customer_id
account.custom_attributes['stripe_customer_id']
end
end
@@ -19,20 +19,9 @@ describe Enterprise::Billing::HandleStripeEventService do
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
})
create(:installation_config, {
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
})
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)
allow(data).to receive(:previous_attributes).and_return({})
allow(subscription).to receive(:[]).with('quantity').and_return('10')
allow(subscription).to receive(:[]).with('status').and_return('active')
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
@@ -63,7 +52,7 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled('audit_logs')
end
it 'resets captain usage on billing period renewal' do
it 'resets captain usage on subscription update' do
# Prime the account with some usage
5.times { account.increment_response_usage }
expect(account.custom_attributes['captain_responses_usage']).to eq(5)
@@ -71,10 +60,6 @@ describe Enterprise::Billing::HandleStripeEventService do
# Setup for any plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
allow(subscription).to receive(:[]).with('current_period_start').and_return(1_686_567_520)
# Simulate billing period renewal with previous_attributes showing old period
allow(data).to receive(:previous_attributes).and_return({ 'current_period_start' => 1_683_975_520 })
stripe_event_service.new.perform(event: event)