Compare commits

...
Author SHA1 Message Date
Tanmay Sharma d9348883ed add default payment method for topup and change of subscription 2025-11-24 19:09:00 +05:30
Tanmay Deep Sharma aeb708a053 Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-11-07 03:39:08 +05:30
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 e3df65ef09 UI for billing portal should setup up billing if customer_id is not present 2025-11-07 02:29:15 +05:30
Tanmay Deep Sharma 063e9654bc stripe_billing_version should be integer two 2025-11-07 02:23:58 +05:30
Tanmay Deep Sharma b7035de400 fix webhook handler spec 2025-11-07 00:05:05 +05:30
Tanmay Deep Sharma 156220e0e2 fix customer creation flow 2025-11-07 00:02:48 +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 051eeae8ec update pricing plan credits and topup 2025-11-06 19:39:15 +05:30
Tanmay Deep Sharma d51cea9d6b Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-11-06 19:18:12 +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 2dcd071dec Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe 2025-11-06 16:36:26 +05:30
Sivin VargheseandGitHub ea34071553 Merge branch 'develop' into feature/stripe_v2 2025-11-06 16:36:14 +05:30
Sivin VargheseandGitHub ce4d52926c Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe 2025-11-06 16:34:03 +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 797714f5aa remove the billing config to render v2 2025-11-06 15:26:26 +05:30
Tanmay Deep Sharma b2cc5d113e update gemfile stripe version to alpha 2025-11-06 15:25:57 +05:30
Tanmay Deep Sharma 4eca7382f8 Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-11-06 14:10:44 +05:30
Tanmay Deep Sharma 0fe60d137e Merge remote-tracking branch 'origin/feature/stripe_v2_fe' into feature/stripe_v2_fe 2025-11-06 14:07:13 +05:30
Tanmay Deep Sharma f6ec3f3a72 use stripe gem beta version 2025-11-06 14:06:48 +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 eb4f93b821 use limits api to render usage 2025-11-05 16:40:47 +05:30
Tanmay Deep Sharma 4a0c61ddb7 use account limits to store the captain limits 2025-11-05 16:38:29 +05:30
Sivin VargheseandGitHub dc4dfe60e4 Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe 2025-11-05 15:34:45 +05:30
Tanmay Deep Sharma d3b4c7225a Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-11-05 13:17:18 +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
Shivam Mishra 3a4aa5f837 faet: update design for upgrade modal 2025-11-04 15:18:26 +05:30
Shivam Mishra bfb2d2b2bb fix: topup dialog discount 2025-11-04 14:46:39 +05:30
Shivam Mishra aa0f36245f fix: credit topup 2025-11-04 14:40:37 +05:30
Shivam Mishra 8f83d03354 feat: update credit balance UI 2025-11-04 14:15:32 +05:30
Shivam Mishra 643f7415c1 feat: cleanup plan summary 2025-11-04 13:43:56 +05:30
iamsivin ebee38d4a5 chore: Update num of seats design 2025-11-04 13:30:13 +05:30
Sivin VargheseandGitHub 16ac2dba17 Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe 2025-11-04 11:28:10 +05:30
Tanmay Deep Sharma e511527f32 Merge remote-tracking branch 'origin/develop' into feature/stripe_v2 2025-11-04 11:25:28 +05:30
iamsivin bd19d596b3 chore: Minor fix 2025-11-03 19:37:15 +05:30
iamsivin 6a0878f893 chore: Minor fix 2025-11-03 19:16:09 +05:30
iamsivin cdececcc67 chore: Clean up 2025-11-03 18:27:08 +05:30
iamsivin ffdcee7d86 chore: Improve transition 2025-11-03 17:32:01 +05:30
iamsivin 05c4ee099b chore: Fix flag 2025-11-03 16:26:44 +05:30
iamsivin 643316e19a chore: Update credit sections 2025-11-03 11:59:12 +05:30
iamsivin a0cc8e2f3f chore: Update the Pricing plan section 2025-10-31 18:03:45 +05:30
iamsivin fb0f5cd696 chore: Update credit grants 2025-10-31 15:46:52 +05:30
iamsivin 6211406681 chore: Minor fix 2025-10-31 14:18:54 +05:30
iamsivin 76ff8bdd35 chore: Update cancel sub modal 2025-10-31 14:17:25 +05:30
Sivin VargheseandGitHub 10cd5da09d Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe 2025-10-30 20:54:27 +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 32e0915a20 create and pay an invoice on subscription change 2025-10-30 15:51:51 +05:30
Tanmay Deep Sharma 94985bbc77 remove update subscription flow overall 2025-10-29 21:37:06 +05:30
Tanmay Deep Sharma 65559b47fd remove update subscription flow overall from UI 2025-10-29 21:36:45 +05:30
Tanmay Deep Sharma 40608f4494 remove update subscription flow overall 2025-10-29 21:35:08 +05:30
Tanmay Deep Sharma f31d9a3e76 store next billing dates 2025-10-29 20:30:16 +05:30
Tanmay Deep Sharma eb94eac9a2 show next billing details 2025-10-29 20:29:47 +05:30
Tanmay Deep Sharma 52f673e56c store next billing dates 2025-10-29 20:29:09 +05:30
Tanmay Deep Sharma 4aa567ee3d Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-10-29 18:43:19 +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 24606ab5ef handle cancellation properly 2025-10-29 15:49:41 +05:30
Tanmay Deep Sharma dd54cf5af3 Merge remote-tracking branch 'origin/feature/stripe_v2' into feature/stripe_v2_fe 2025-10-29 03:59:13 +05:30
Tanmay Deep Sharma b91f8012cd add pricing plan api fix 2025-10-29 03:58:44 +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 abeb938bfd remove topup list if on hacker plan 2025-10-29 03:05:49 +05:30
Tanmay Deep Sharma 81c1ab73e6 sanity changes 2025-10-29 03:03:10 +05:30
Tanmay Deep Sharma 1689d5cf5c add fe changes 2025-10-28 22:59:57 +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
65 changed files with 4982 additions and 259 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
@@ -23,6 +23,35 @@ class EnterpriseAccountAPI extends ApiClient {
action_type: action,
});
}
// V2 Billing APIs
creditGrants() {
return axios.get(`${this.url}credit_grants`);
}
v2PricingPlans() {
return axios.get(`${this.url}v2_pricing_plans`);
}
v2TopupOptions() {
return axios.get(`${this.url}v2_topup_options`);
}
v2Topup(data) {
return axios.post(`${this.url}v2_topup`, data);
}
v2Subscribe(data) {
return axios.post(`${this.url}v2_subscribe`, data);
}
cancelSubscription(data = {}) {
return axios.post(`${this.url}cancel_subscription`, data);
}
changePricingPlan(data) {
return axios.post(`${this.url}change_pricing_plan`, data);
}
}
export default new EnterpriseAccountAPI();
@@ -0,0 +1,61 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
defineProps({
color: {
type: String,
default: 'blue',
validator: value => ['ruby', 'blue', 'teal', 'amber'].includes(value),
},
icon: {
type: String,
default: 'i-lucide-info',
},
title: {
type: String,
default: null,
},
message: {
type: String,
default: null,
},
});
const colorClasses = {
ruby: 'outline-n-ruby-4 bg-n-ruby-3 text-n-ruby-11',
blue: 'outline-n-blue-4 bg-n-blue-3 text-n-blue-11',
teal: 'outline-n-teal-4 bg-n-teal-3 text-n-teal-11',
amber: 'outline-n-amber-4 bg-n-amber-3 text-n-amber-11',
};
const titleColorClasses = {
ruby: 'text-n-ruby-11',
blue: 'text-n-blue-11',
teal: 'text-n-teal-11',
amber: 'text-n-amber-11',
};
</script>
<template>
<div
class="p-4 rounded-xl outline outline-1 -outline-offset-1"
:class="colorClasses[color]"
>
<div class="flex gap-3 items-start">
<Icon :icon="icon" class="flex-shrink-0 mt-0.5" />
<div class="flex-1 space-y-1">
<h4
v-if="title"
class="text-sm font-semibold"
:class="titleColorClasses[color]"
>
{{ title }}
</h4>
<p v-if="message" class="mb-0 text-sm">
{{ message }}
</p>
<slot />
</div>
</div>
</div>
</template>
+1
View File
@@ -41,6 +41,7 @@ export const FEATURE_FLAGS = {
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
STRIPE_V2: 'stripe_v2',
};
export const PREMIUM_FEATURES = [
@@ -382,6 +382,11 @@
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
"BUTTON_TXT": "Go to the billing portal"
},
"V2_BILLING_PORTAL": {
"TITLE": "Manage your billing",
"DESCRIPTION": "View your credits balance, top up, manage subscriptions, and view transactions.",
"BUTTON_TXT": "Go to billing dashboard"
},
"CAPTAIN": {
"TITLE": "Captain",
"DESCRIPTION": "Manage usage and credits for Captain AI.",
@@ -397,6 +402,120 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"BILLING_SETTINGS_V2": {
"TITLE": "Billing",
"DESCRIPTION": "Manage your usage-based subscription with flexible credit-based pricing. Top up anytime or subscribe to a plan.",
"VIEW_PRICING": "View Pricing",
"OPEN_STRIPE_DASHBOARD": "Open Stripe Dashboard",
"CREDITS_BALANCE": {
"TITLE": "Captain Credits",
"DESCRIPTION": "Your current credit balance and recent transactions.",
"CURRENT_BALANCE": "Current Balance",
"CREDITS": "credits",
"REFRESH": "Refresh",
"BUY_CREDITS": "Buy Credits",
"VIEW_HISTORY": "View History",
"MONTHLY_CREDITS": "Monthly Credits",
"TOPUP_CREDITS": "Top-up Credits",
"USED_THIS_MONTH": "Used This Month",
"TOTAL_USED": "Total Used",
"USAGE_BASED_INFO": "Credits are consumed as you use Captain"
},
"PLAN_SUMMARY": {
"NAME": "Plan Name",
"PRICE": "Price",
"PER_MONTH": "{price}/month",
"RENEWAL_DATE": "Renewal Date",
"CREDITS_INCLUDED": "Captain AI Credits Included",
"NUMBER_OF_SEATS": "Number of Seats",
"ADJUST_SEATS_HINT": "Adjust seats to scale your plan",
"UPDATE": "Update",
"VIEW_ALL_PLANS": "View All Plans",
"CANCEL_PLAN": "Cancel Plan"
},
"PRICING_PLANS": {
"TITLE": "Pricing Plans",
"DESCRIPTION": "Choose a plan that fits your team. All plans include credits that reset monthly.",
"CURRENT_PLAN": "Current Subscription",
"RECOMMENDED": "Recommended",
"MONTH": "month",
"SEATS": "seats",
"CREDITS_PER_SEAT": "credits/month",
"MIN_SEATS": "Minimum seats",
"SUBSCRIBE": "Subscribe",
"SWITCH_PLAN": "Switch Plan",
"BLOCKED": "Blocked",
"PENDING_TITLE": "Pending Changes Scheduled",
"PENDING_MESSAGE": "Your subscription changes will take effect on your next billing date.",
"NEXT_PLAN": "Next Plan",
"NEXT_QUANTITY": "Next Quantity",
"EFFECTIVE_DATE": "Effective Date",
"CANCELLING_TITLE": "Subscription Ending",
"CANCELLING_MESSAGE": "Your subscription will be cancelled at the end of the current billing period. You can continue using your current plan until then.",
"CANCELLING_BLOCKED_INFO": "You cannot change plans or subscribe to new plans while your subscription is ending.",
"ENDS_ON": "Subscription ends on",
"NO_PLANS": "No pricing plans available.",
"SUBSCRIBE_SUCCESS": "Successfully subscribed to the plan! Your credits have been updated.",
"CANCEL_SUCCESS": "Subscription cancelled successfully.",
"UPDATE_QUANTITY_SUCCESS": "Subscription quantity updated successfully!",
"CHANGE_PLAN_SUCCESS": "Successfully changed to the new plan! Your credits have been updated."
},
"TOPUP_OPTIONS": {
"TITLE": "Top-up Credits",
"DESCRIPTION": "Add more credits to your account, to ensure uninterrupted Captain usage.",
"CREDITS": "Credits",
"PRICE": "Price",
"BUY_NOW": "Buy Now",
"NO_OPTIONS": "No top-up options available.",
"TOPUP_SUCCESS": "Credits added successfully! Your new balance is available now.",
"NO_PAYMENT_METHOD_ERROR": "No payment method found. Please add a payment method in your Stripe dashboard before making a purchase. Click the 'Open Stripe Dashboard' button above to add one.",
"UPGRADE_REQUIRED": "Upgrade your plan to purchase top-up credits.",
"UPGRADE_MESSAGE": "Top-ups are only available for Startup, Business, and Enterprise plans. Please upgrade your plan to purchase additional credits."
},
"CREDIT_GRANTS": {
"TITLE": "Credit History",
"CREDITS": "credits",
"EXPIRES": "Expires",
"NO_EXPIRY": "Never expires",
"ADDED": "Added on {date}",
"VOIDED": "Voided",
"NO_GRANTS": "No credit grants yet.",
"TOPUPS": "Topups",
"MONTHLY": "Monthly"
},
"SUBSCRIBE_MODAL": {
"TITLE": "Confirm Subscription",
"DESCRIPTION": "You are subscribing to {planName} for {planPrice} per seat with {credits} included credits for Captain AI.",
"NUMBER_OF_SEATS": "Number of seats",
"MIN": "Min",
"MAX": "Max",
"BASE_PRICE": "Base price per seat",
"INCLUDED_CREDITS": "Included credits",
"CREDITS": "credits",
"MONTHLY_TOTAL": "Monthly Total",
"CANCEL": "Cancel",
"CONFIRM": "Confirm Subscription"
},
"TOPUP_MODAL": {
"TITLE": "Confirm Top-up",
"DESCRIPTION": "Top up your account with {credits} credits for {price}. Your card will be immediately charged.",
"CREDITS": "Credits",
"PRICE": "Price",
"INFO": "Credits will be added to your account immediately and never expire.",
"CANCEL": "Cancel",
"CONFIRM": "Confirm Top-up"
},
"CANCEL_MODAL": {
"TITLE": "Cancel Subscription",
"WARNING_TITLE": "Are you sure?",
"WARNING_MESSAGE": "Cancelling your subscription will stop monthly credit renewals. Any remaining credits will still be available until used.",
"REASON_LABEL": "Reason for cancellation (optional)",
"REASON_PLACEHOLDER": "Help us improve by sharing why you're cancelling...",
"INFO": "You can resubscribe anytime to continue receiving monthly credits.",
"KEEP_SUBSCRIPTION": "Keep Subscription",
"CONFIRM_CANCEL": "Confirm Cancellation"
}
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
@@ -0,0 +1,141 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import CreditsSection from './components/CreditsSection.vue';
import PricingPlans from './components/PricingPlans.vue';
import BillingHeader from '../billing/components/BillingHeader.vue';
import ButtonV4 from 'next/button/Button.vue';
const router = useRouter();
const store = useStore();
const { isOnChatwootCloud, currentAccount } = useAccount();
const uiFlags = useMapGetter('accounts/getUIFlags');
const v2BillingData = useMapGetter('accounts/getV2BillingData');
const v2BillingUIFlags = useMapGetter('accounts/getV2BillingUIFlags');
const isLoading = computed(() => {
return (
uiFlags.value.isFetchingItem ||
v2BillingUIFlags.value.isFetchingGrants ||
v2BillingUIFlags.value.isFetchingPlans ||
v2BillingUIFlags.value.isFetchingTopupOptions
);
});
const canUseTopup = computed(() => {
const planName = currentAccount.value?.custom_attributes?.plan_name;
// Block topup if no plan or on Hacker plan
if (!planName || planName.toLowerCase() === 'hacker') {
return false;
}
return true;
});
const handleInitialLoad = async () => {
// If self-hosted, redirect to dashboard
if (!isOnChatwootCloud.value) {
router.push({ name: 'home' });
return;
}
// Fetch all V2 billing data in parallel
await Promise.all([
store.dispatch('accounts/fetchCreditsBalance'),
store.dispatch('accounts/fetchCreditGrants'),
store.dispatch('accounts/fetchV2PricingPlans'),
store.dispatch('accounts/fetchV2TopupOptions'),
store.dispatch('accounts/subscription'),
]);
};
const handleRefresh = () => {
store.dispatch('accounts/fetchCreditsBalance');
store.dispatch('accounts/fetchCreditGrants');
store.dispatch('accounts/fetchV2PricingPlans');
};
const onToggleChatWindow = () => {
if (window.$chatwoot) {
window.$chatwoot.toggle();
}
};
const handleOpenStripeDashboard = async () => {
await store.dispatch('accounts/checkout');
};
onMounted(handleInitialLoad);
</script>
<template>
<SettingsLayout :is-loading="isLoading">
<template #header>
<BaseSettingsHeader
:title="$t('BILLING_SETTINGS_V2.TITLE')"
:description="$t('BILLING_SETTINGS_V2.DESCRIPTION')"
:link-text="$t('BILLING_SETTINGS_V2.VIEW_PRICING')"
feature-name="billing-v2"
>
<template #actions>
<ButtonV4
solid
blue
icon="i-lucide-external-link"
:is-loading="uiFlags.isCheckoutInProcess"
@click="handleOpenStripeDashboard"
>
{{ $t('BILLING_SETTINGS_V2.OPEN_STRIPE_DASHBOARD') }}
</ButtonV4>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<section class="grid gap-6">
<!-- Pricing Plans -->
<PricingPlans
:plans="v2BillingData.pricingPlans"
:current-account="currentAccount"
:is-loading="v2BillingUIFlags.isFetchingPlans"
:is-subscribing="v2BillingUIFlags.isSubscribeInProcess"
:is-canceling="v2BillingUIFlags.isCancelInProcess"
/>
<!-- Credits Section -->
<CreditsSection
:balance-data="v2BillingData.creditsBalance"
:topup-options="v2BillingData.topupOptions"
:credit-grants="v2BillingData.creditGrants"
:is-loading-balance="v2BillingUIFlags.isFetchingBalance"
:is-loading-topup="v2BillingUIFlags.isFetchingTopupOptions"
:is-loading-grants="v2BillingUIFlags.isFetchingGrants"
:is-processing-topup="v2BillingUIFlags.isTopupInProcess"
:can-use-topup="canUseTopup"
@refresh="handleRefresh"
/>
<!-- Chat Support -->
<BillingHeader
class="px-1 mt-5"
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
>
<ButtonV4
sm
solid
slate
icon="i-lucide-life-buoy"
@click="onToggleChatWindow"
>
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
</ButtonV4>
</BillingHeader>
</section>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,67 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Notice from 'dashboard/components-next/notice/Notice.vue';
defineProps({
isCanceling: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['cancel']);
const { t } = useI18n();
const dialogRef = ref(null);
const reason = ref('');
const handleCancel = () => {
emit('cancel', reason.value);
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
width="xl"
:title="t('BILLING_SETTINGS_V2.CANCEL_MODAL.TITLE')"
:confirm-button-label="t('BILLING_SETTINGS_V2.CANCEL_MODAL.CONFIRM_CANCEL')"
:cancel-button-label="
t('BILLING_SETTINGS_V2.CANCEL_MODAL.KEEP_SUBSCRIPTION')
"
:is-loading="isCanceling"
@confirm="handleCancel"
>
<div class="space-y-4">
<Notice
color="ruby"
icon="i-lucide-alert-triangle"
:title="t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_TITLE')"
:message="t('BILLING_SETTINGS_V2.CANCEL_MODAL.WARNING_MESSAGE')"
/>
<TextArea
v-model="reason"
:label="t('BILLING_SETTINGS_V2.CANCEL_MODAL.REASON_LABEL')"
:placeholder="t('BILLING_SETTINGS_V2.CANCEL_MODAL.REASON_PLACEHOLDER')"
:max-length="1500"
auto-height
/>
<div class="flex gap-1 items-center">
<Icon icon="i-lucide-info" class="flex-shrink-0 size-3.5" />
<p class="text-xs text-n-slate-12">
{{ t('BILLING_SETTINGS_V2.CANCEL_MODAL.INFO') }}
</p>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,130 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import format from 'date-fns/format';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
grants: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const formatDate = dateStr => {
if (!dateStr) return null;
return format(new Date(dateStr), 'MMM dd, yyyy');
};
const formatNumber = num => new Intl.NumberFormat('en-US').format(num);
const sortedGrants = computed(
() =>
[...props.grants]?.sort((a, b) => {
if (a.voided_at && !b.voided_at) return 1;
if (!a.voided_at && b.voided_at) return -1;
return new Date(b.created_at) - new Date(a.created_at);
}) || []
);
const CATEGORY_CONFIG = computed(() => {
return {
paid: {
label: t('BILLING_SETTINGS_V2.CREDIT_GRANTS.TOPUPS'),
class: 'bg-n-blue-3 text-n-blue-11 outline outline-1 outline-n-blue-4',
},
granted: {
label: t('BILLING_SETTINGS_V2.CREDIT_GRANTS.MONTHLY'),
class: 'bg-n-teal-3 text-n-teal-11 outline outline-1 outline-n-teal-4',
},
};
});
const getCategoryConfig = category =>
CATEGORY_CONFIG.value[category] || CATEGORY_CONFIG.value.granted;
</script>
<template>
<div class="mt-4 max-h-60 overflow-y-auto px-3 mx-2">
<h6 class="text-base font-semibold mb-2">
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.TITLE') }}
</h6>
<div v-if="sortedGrants.length" class="space-y-2">
<div
v-for="grant in sortedGrants"
:key="grant.id"
class="flex items-center justify-between gap-4 p-3 rounded-lg -outline-offset-1 outline outline-1 outline-n-weak"
:class="{ 'opacity-60': grant.voided_at }"
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<span
class="px-2 py-0.5 text-xs font-semibold rounded whitespace-nowrap"
:class="getCategoryConfig(grant.category).class"
>
{{ getCategoryConfig(grant.category).label }}
</span>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ grant.name }}
</span>
</div>
<div class="flex items-center gap-4 flex-shrink-0">
<div
class="text-sm font-semibold text-n-slate-12 whitespace-nowrap hidden sm:block"
>
{{ formatNumber(grant.credits) }}
<span class="text-xs font-normal text-n-slate-10 ml-1">
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.CREDITS') }}
</span>
</div>
<div class="text-sm text-right min-w-[110px]">
<span
v-if="grant.expires_at"
class="font-medium text-n-slate-11"
:title="t('BILLING_SETTINGS_V2.CREDIT_GRANTS.EXPIRES')"
>
{{ formatDate(grant.expires_at) }}
</span>
<span v-else class="font-medium text-n-teal-10">
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.NO_EXPIRY') }}
</span>
</div>
<div
v-if="grant.created_at"
class="text-xs text-n-slate-10 whitespace-nowrap"
:title="
t('BILLING_SETTINGS_V2.CREDIT_GRANTS.ADDED', {
date: formatDate(grant.created_at),
})
"
>
{{ formatDate(grant.created_at) }}
</div>
<span
v-if="grant.voided_at"
class="px-2 py-0.5 text-xs font-semibold rounded bg-n-ruby-3 text-n-ruby-11 border border-n-ruby-6 whitespace-nowrap"
>
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.VOIDED') }}
</span>
</div>
</div>
</div>
<div v-else-if="!isLoading" class="py-8 text-center">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.CREDIT_GRANTS.NO_GRANTS') }}
</p>
</div>
<div v-else class="py-8 flex justify-center items-center">
<Spinner class="text-n-brand" />
</div>
</div>
</template>
@@ -0,0 +1,166 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
balanceData: {
type: Object,
default: () => null,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['refresh', 'viewTopup', 'viewHistory']);
const { t } = useI18n();
const currentBalance = computed(() => {
return props.balanceData?.current_available || 0;
});
const monthlyCredits = computed(() => {
return props.balanceData?.monthly_credits || 0;
});
const topupCredits = computed(() => {
return props.balanceData?.topup_credits || 0;
});
const usageThisMonth = computed(() => {
return props.balanceData?.usage_this_month || 0;
});
const usageTotal = computed(() => {
return props.balanceData?.usage_total || 0;
});
const formatAmount = amount => {
if (!amount) return '0';
return new Intl.NumberFormat('en-US').format(amount);
};
</script>
<template>
<div class="rounded-xl mx-5 mt-5">
<div
class="flex gap-6 mb-4 items-center justify-evenly rounded-xl dark:bg-n-solid-1 bg-n-slate-2 px-4 py-3"
>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.MONTHLY_CREDITS') }}
</p>
<div class="flex items-center gap-1.5">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ formatAmount(monthlyCredits) }}
</p>
</div>
</div>
<div
class="bg-n-background rounded-lg grid place-content-center h-5 w-6 shadow-sm outline outline-1 outline-n-weak"
>
<Icon icon="i-lucide-plus" class="size-4 text-n-blue-8" />
</div>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.TOPUP_CREDITS') }}
</p>
<div class="flex items-center gap-1.5">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ formatAmount(topupCredits) }}
</p>
</div>
</div>
<div
class="bg-n-background rounded-lg grid place-content-center h-5 w-6 shadow-sm outline outline-1 outline-n-weak"
>
<Icon icon="i-lucide-minus" class="size-4 text-n-ruby-8" />
</div>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.USED_THIS_MONTH') }}
</p>
<div class="flex items-center gap-1.5">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ formatAmount(usageThisMonth) }}
</p>
</div>
</div>
<div
class="bg-n-background rounded-lg grid place-content-center h-5 w-6 shadow-sm outline outline-1 outline-n-weak"
>
<Icon icon="i-lucide-equal" class="size-4 text-n-teal-8" />
</div>
<div>
<span class="flex items-center gap-2 mb-1">
<div class="text-xs text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.CURRENT_BALANCE') }}
</div>
<Icon
v-tooltip="
t('BILLING_SETTINGS_V2.CREDITS_BALANCE.USAGE_BASED_INFO')
"
icon="i-lucide-circle-question-mark"
class="size-3 text-n-slate-10"
/>
</span>
<div class="text-lg font-semibold text-n-slate-12">
{{ formatAmount(currentBalance) }}
</div>
</div>
<div class="hidden">
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.TOTAL_USED') }}
</p>
<div class="flex items-center gap-1.5">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ formatAmount(usageTotal) }}
</p>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2">
<div class="space-x-2">
<Button
variant="faded"
color="slate"
size="sm"
icon="i-lucide-refresh-cw"
:is-loading="isLoading"
@click="emit('refresh')"
>
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.REFRESH') }}
</Button>
<Button
variant="faded"
color="slate"
size="sm"
icon="i-lucide-history"
@click="emit('viewHistory')"
>
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.VIEW_HISTORY') }}
</Button>
</div>
<Button
color="teal"
size="sm"
icon="i-lucide-plus"
@click="emit('viewTopup')"
>
{{ t('BILLING_SETTINGS_V2.CREDITS_BALANCE.BUY_CREDITS') }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,123 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import BillingCard from '../../billing/components/BillingCard.vue';
import CreditsBalance from './CreditsBalance.vue';
import TopupOptions from './TopupOptions.vue';
import CreditGrants from './CreditGrants.vue';
import Notice from 'dashboard/components-next/notice/Notice.vue';
defineProps({
balanceData: {
type: Object,
default: () => null,
},
topupOptions: {
type: Array,
default: () => [],
},
creditGrants: {
type: Array,
default: () => [],
},
isLoadingBalance: {
type: Boolean,
default: false,
},
isLoadingTopup: {
type: Boolean,
default: false,
},
isLoadingGrants: {
type: Boolean,
default: false,
},
isProcessingTopup: {
type: Boolean,
default: false,
},
canUseTopup: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['refresh']);
const { t } = useI18n();
const showTopupOptions = ref(false);
const showCreditHistory = ref(false);
const handleViewTopup = () => {
showTopupOptions.value = !showTopupOptions.value;
if (showTopupOptions.value) {
showCreditHistory.value = false;
}
};
const handleViewHistory = () => {
showCreditHistory.value = !showCreditHistory.value;
if (showCreditHistory.value) {
showTopupOptions.value = false;
}
};
const handleRefresh = () => {
emit('refresh');
};
</script>
<template>
<BillingCard
:title="t('BILLING_SETTINGS_V2.CREDITS_BALANCE.TITLE')"
:description="t('BILLING_SETTINGS_V2.CREDITS_BALANCE.DESCRIPTION')"
>
<CreditsBalance
:balance-data="balanceData"
:is-loading="isLoadingBalance"
@refresh="handleRefresh"
@view-topup="handleViewTopup"
@view-history="handleViewHistory"
/>
<div
class="transition-all duration-300 ease-out grid overflow-hidden mx-5 !mt-0"
:class="
showTopupOptions
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<TopupOptions
v-if="canUseTopup"
:options="topupOptions"
:is-loading="isLoadingTopup"
:is-processing="isProcessingTopup"
/>
<div v-else class="mt-4">
<Notice
color="amber"
icon="i-lucide-alert-triangle"
:title="t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.UPGRADE_REQUIRED')"
:message="t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.UPGRADE_MESSAGE')"
/>
</div>
</div>
</div>
<div
class="transition-all duration-300 ease-out grid overflow-hidden !mt-0"
:class="
showCreditHistory
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<CreditGrants :grants="creditGrants" :is-loading="isLoadingGrants" />
</div>
</div>
</BillingCard>
</template>
@@ -0,0 +1,159 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import format from 'date-fns/format';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SeatManager from './SeatManager.vue';
const props = defineProps({
planName: {
type: String,
required: true,
},
pricePerMonth: {
type: Number,
required: true,
},
totalPrice: {
type: Number,
required: true,
},
renewalDate: {
type: String,
default: null,
},
currentSeats: {
type: Number,
required: true,
},
minSeats: {
type: Number,
default: 1,
},
includedCredits: {
type: Number,
required: true,
},
isCancelling: {
type: Boolean,
default: false,
},
isUpdatingSeats: {
type: Boolean,
default: false,
},
updatingDirection: {
type: String,
default: null,
},
});
const emit = defineEmits(['viewAllPlans', 'cancelPlan', 'updateSeats']);
const { t } = useI18n();
const formattedRenewalDate = computed(() => {
if (!props.renewalDate) return null;
return format(new Date(props.renewalDate), 'MMM dd, yyyy');
});
const formatPrice = price => {
if (!price) return '$0';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(price);
};
const formatNumber = num => {
return new Intl.NumberFormat('en-US').format(num);
};
</script>
<template>
<div class="rounded-xl mx-5 mt-5">
<div class="grid grid-cols-4 gap-6 mb-4">
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.NAME') }}
</p>
<div class="items-center gap-1">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ planName }}
</p>
<p class="text-xs text-n-slate-11 mb-0">
{{
t('BILLING_SETTINGS_V2.PLAN_SUMMARY.PER_MONTH', {
price: formatPrice(pricePerMonth),
})
}}
</p>
</div>
</div>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.PRICE') }}
</p>
<div class="flex items-center gap-1">
<p class="text-lg font-semibold text-n-slate-12 mb-0">
{{ formatPrice(totalPrice) }}
</p>
</div>
</div>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.RENEWAL_DATE') }}
</p>
<p class="text-lg font-semibold text-n-slate-12">
{{ formattedRenewalDate || '—' }}
</p>
</div>
<div>
<p class="text-xs text-n-slate-11 mb-1">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.CREDITS_INCLUDED') }}
</p>
<div class="flex items-center gap-1.5">
<Icon icon="i-lucide-sparkles" class="text-n-amber-9" />
<p class="text-lg font-semibold text-n-slate-12">
{{ formatNumber(includedCredits) }}
</p>
</div>
</div>
</div>
<SeatManager
v-if="!isCancelling"
:current-seats="currentSeats"
:min-seats="minSeats"
:is-updating-seats="isUpdatingSeats"
:updating-direction="updatingDirection"
class="mb-4"
@update-seats="emit('updateSeats', $event)"
/>
<div class="flex items-center justify-end gap-2">
<Button
variant="faded"
color="slate"
size="sm"
@click="emit('viewAllPlans')"
>
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.VIEW_ALL_PLANS') }}
</Button>
<Button
v-if="!isCancelling"
variant="faded"
color="ruby"
size="sm"
@click="emit('cancelPlan')"
>
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.CANCEL_PLAN') }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
const props = defineProps({
plans: {
type: Array,
default: () => [],
},
currentPlanId: {
type: String,
default: null,
},
hasActiveSubscription: {
type: Boolean,
default: false,
},
isCancellingAtPeriodEnd: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select-plan']);
const { t } = useI18n();
const isCurrentPlan = plan => {
return plan.id === props.currentPlanId;
};
const formatPrice = price => {
if (!price) return '$0';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(price);
};
const formatNumber = num => {
return new Intl.NumberFormat('en-US').format(num);
};
</script>
<template>
<div class="mt-5">
<div class="grid gap-4 grid-cols-1 xs:grid-cols-2 xl:grid-cols-4">
<div
v-for="plan in plans"
:key="plan.id || plan.display_name"
class="relative p-4 rounded-xl -outline-offset-1 outline outline-1 transition-all"
:class="
isCurrentPlan(plan)
? 'outline-n-blue-8 bg-n-blue-3'
: 'outline-n-weak hover:outline-n-strong hover:shadow-sm'
"
>
<div
v-if="plan.recommended"
class="absolute -top-2 ltr:right-2 rtl:left-2 px-2 py-0.5 bg-n-teal-9 text-white text-xs font-semibold rounded-md"
>
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.RECOMMENDED') }}
</div>
<h5 class="text-lg font-bold text-n-slate-12">{{ plan.name }}</h5>
<p class="mt-1 text-sm text-n-slate-11">{{ plan.description }}</p>
<div class="mt-3">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold text-n-slate-12">
{{ formatPrice(plan.base_price) }}
</span>
<span class="text-sm text-n-slate-11">
/{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.MONTH') }}
</span>
</div>
</div>
<div class="mt-3 space-y-2 text-sm">
<div class="flex items-center gap-2">
<Icon icon="i-lucide-check" class="text-n-teal-9" />
<span class="text-n-slate-11">
{{ formatNumber(plan.included_credits) }}
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.CREDITS_PER_SEAT') }}
</span>
</div>
<div v-if="plan.min_seats > 1" class="flex items-center gap-2">
<Icon icon="i-lucide-info" class="text-n-blue-9" />
<span class="text-n-slate-11 text-xs">
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.MIN_SEATS') }}:
{{ plan.min_seats }}
</span>
</div>
</div>
<Button
class="w-full mt-4"
sm
:solid="plan.recommended"
:faded="!plan.recommended"
:blue="plan.recommended"
:slate="!plan.recommended"
:disabled="isCurrentPlan(plan) || isCancellingAtPeriodEnd"
@click="emit('select-plan', plan)"
>
{{
isCurrentPlan(plan)
? t('BILLING_SETTINGS_V2.PRICING_PLANS.CURRENT_PLAN')
: isCancellingAtPeriodEnd
? t('BILLING_SETTINGS_V2.PRICING_PLANS.BLOCKED')
: hasActiveSubscription
? t('BILLING_SETTINGS_V2.PRICING_PLANS.SWITCH_PLAN')
: t('BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE')
}}
</Button>
</div>
</div>
<div v-if="plans.length === 0 && !isLoading" class="py-8 text-center">
<p class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NO_PLANS') }}
</p>
</div>
</div>
</template>
@@ -0,0 +1,294 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert } from 'dashboard/composables';
import format from 'date-fns/format';
import BillingCard from '../../billing/components/BillingCard.vue';
import PlanSummary from './PlanSummary.vue';
import PlansGrid from './PlansGrid.vue';
import Notice from 'dashboard/components-next/notice/Notice.vue';
import SubscribeDialog from './SubscribeDialog.vue';
import CancelSubscriptionDialog from './CancelSubscriptionDialog.vue';
const props = defineProps({
plans: {
type: Array,
default: () => [],
},
currentAccount: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isSubscribing: {
type: Boolean,
default: false,
},
isCanceling: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const store = useStore();
const showAllPlans = ref(false);
const cancelModalRef = ref(null);
const subscribeDialogRef = ref(null);
const selectedPlan = ref(null);
const isUpdatingSeats = ref(false);
const updatingDirection = ref(null);
const customAttributes = computed(
() => props.currentAccount.custom_attributes || {}
);
const currentPlanId = computed(
() => customAttributes.value.stripe_pricing_plan_id
);
const currentPlanQuantity = computed(
() => customAttributes.value.subscribed_quantity || 0
);
const subscriptionStatus = computed(
() => customAttributes.value.subscription_status
);
const subscriptionEndsAt = computed(
() => customAttributes.value.subscription_ends_at
);
const stripeSubscriptionId = computed(
() => customAttributes.value.stripe_subscription_id
);
const pendingPlanId = computed(
() => customAttributes.value.pending_stripe_pricing_plan_id
);
const pendingQuantity = computed(
() => customAttributes.value.pending_subscription_quantity
);
const nextBillingDate = computed(
() => customAttributes.value.next_billing_date
);
const hasActiveSubscription = computed(() => !!currentPlanId.value);
const hasStripeSubscription = computed(() => !!stripeSubscriptionId.value);
const isCancellingAtPeriodEnd = computed(
() => subscriptionStatus.value === 'cancel_at_period_end'
);
const hasPendingChanges = computed(
() => !!pendingPlanId.value || !!pendingQuantity.value
);
const formatEndDate = () =>
subscriptionEndsAt.value
? format(new Date(subscriptionEndsAt.value), 'MMMM dd, yyyy')
: '';
const formatNextBillingDate = () =>
nextBillingDate.value
? format(new Date(nextBillingDate.value), 'MMMM dd, yyyy')
: '';
// Transform backend component structure to flat structure
const transformedPlans = computed(() => {
return props.plans.map(plan => {
const components = plan.components || [];
// Extract values from components array
const serviceAction = components.find(c => c.type === 'service_action');
const licenseFeeLike = components.find(c => c.type === 'license_fee');
const rateCard = components.find(c => c.type === 'rate_card');
return {
...plan,
name: plan.display_name || plan.name,
description: plan.description || '',
base_price: licenseFeeLike?.unit_amount || 0,
included_credits: serviceAction?.credit_amount || 0,
overage_rate: rateCard?.overage_rate || 0,
min_seats: plan.min_seats || 1,
recommended: plan.recommended || false,
};
});
});
const currentPlan = computed(() => {
if (!currentPlanId.value) return null;
return transformedPlans.value.find(plan => plan.id === currentPlanId.value);
});
const pendingPlan = computed(() => {
if (!pendingPlanId.value) return null;
return transformedPlans.value.find(plan => plan.id === pendingPlanId.value);
});
const openSubscribeModal = plan => {
selectedPlan.value = plan;
subscribeDialogRef.value?.dialogRef?.open();
};
const openCancelModal = () => {
cancelModalRef.value?.dialogRef?.open();
};
const handleSubscribe = async data => {
// Use change plan API only if stripe_subscription_id exists
// Otherwise, always use subscribe API
const action = hasStripeSubscription.value ? 'v2ChangePlan' : 'v2Subscribe';
const result = await store.dispatch(`accounts/${action}`, data);
if (result.success) {
const msgKey = hasStripeSubscription.value
? 'BILLING_SETTINGS_V2.PRICING_PLANS.CHANGE_PLAN_SUCCESS'
: 'BILLING_SETTINGS_V2.PRICING_PLANS.SUBSCRIBE_SUCCESS';
useAlert(t(msgKey));
subscribeDialogRef.value?.dialogRef?.close();
} else if (result.error) {
useAlert(result.error);
}
};
const handleCancelSubscription = async reason => {
const result = await store.dispatch('accounts/v2CancelSubscription', {
reason,
});
if (result.success) {
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCEL_SUCCESS'));
cancelModalRef.value?.dialogRef?.close();
} else if (result.error) {
useAlert(result.error);
}
};
const handleUpdateSeats = async ({ quantity, direction }) => {
isUpdatingSeats.value = true;
updatingDirection.value = direction;
try {
const result = await store.dispatch('accounts/v2UpdateQuantity', {
pricingPlanId: currentPlanId.value,
quantity,
});
if (result.success) {
useAlert(t('BILLING_SETTINGS_V2.PRICING_PLANS.UPDATE_QUANTITY_SUCCESS'));
} else if (result.error) {
useAlert(result.error);
}
} finally {
isUpdatingSeats.value = false;
updatingDirection.value = null;
}
};
</script>
<template>
<div>
<BillingCard
:title="$t('BILLING_SETTINGS_V2.PRICING_PLANS.TITLE')"
:description="$t('BILLING_SETTINGS_V2.PRICING_PLANS.DESCRIPTION')"
>
<Notice
v-if="isCancellingAtPeriodEnd"
color="amber"
icon="i-lucide-alert-triangle"
:title="$t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_TITLE')"
:message="$t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_MESSAGE')"
class="mx-5"
>
<p v-if="subscriptionEndsAt" class="mt-2 mb-0 text-sm">
<span class="font-semibold">
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.ENDS_ON') }}:
</span>
{{ formatEndDate() }}
</p>
<p class="mt-1 mb-0 text-sm">
{{ $t('BILLING_SETTINGS_V2.PRICING_PLANS.CANCELLING_BLOCKED_INFO') }}
</p>
</Notice>
<Notice
v-if="hasPendingChanges && !isCancellingAtPeriodEnd"
color="blue"
icon="i-lucide-clock"
:title="t('BILLING_SETTINGS_V2.PRICING_PLANS.PENDING_TITLE')"
:message="t('BILLING_SETTINGS_V2.PRICING_PLANS.PENDING_MESSAGE')"
class="mx-5 mt-5"
>
<div v-if="pendingPlan" class="mt-2 text-sm">
<span class="font-semibold">
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NEXT_PLAN') }}:
</span>
<span>{{ pendingPlan.name }}</span>
</div>
<div v-if="pendingQuantity" class="mt-1 text-sm">
<span class="font-semibold">
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.NEXT_QUANTITY') }}:
</span>
<span>
{{ pendingQuantity }}
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.SEATS') }}
</span>
</div>
<p v-if="nextBillingDate" class="mt-2 mb-0 text-sm">
<span class="font-semibold">
{{ t('BILLING_SETTINGS_V2.PRICING_PLANS.EFFECTIVE_DATE') }}:
</span>
{{ formatNextBillingDate() }}
</p>
</Notice>
<PlanSummary
v-if="hasActiveSubscription && currentPlan"
:plan-name="currentPlan.name"
:price-per-month="currentPlan.base_price"
:total-price="currentPlan.base_price * currentPlanQuantity"
:renewal-date="nextBillingDate"
:current-seats="currentPlanQuantity"
:min-seats="currentPlan.min_seats || 1"
:included-credits="currentPlan.included_credits"
:is-cancelling="isCancellingAtPeriodEnd"
:is-updating-seats="isUpdatingSeats"
:updating-direction="updatingDirection"
@view-all-plans="showAllPlans = !showAllPlans"
@cancel-plan="openCancelModal"
@update-seats="handleUpdateSeats"
/>
<div
class="transition-all duration-300 ease-out grid overflow-hidden mx-5 !mt-0"
:class="
!hasActiveSubscription || showAllPlans
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden">
<PlansGrid
:plans="transformedPlans"
:current-plan-id="currentPlanId"
:has-active-subscription="hasActiveSubscription"
:is-cancelling-at-period-end="isCancellingAtPeriodEnd"
:is-loading="isLoading"
@select-plan="openSubscribeModal"
/>
</div>
</div>
</BillingCard>
<SubscribeDialog
ref="subscribeDialogRef"
:plan="selectedPlan"
:is-subscribing="isSubscribing"
@subscribe="handleSubscribe"
/>
<CancelSubscriptionDialog
ref="cancelModalRef"
:is-canceling="isCanceling"
@cancel="handleCancelSubscription"
/>
</div>
</template>
@@ -0,0 +1,205 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
currentSeats: {
type: Number,
required: true,
},
minSeats: {
type: Number,
default: 1,
},
isUpdatingSeats: {
type: Boolean,
default: false,
},
updatingDirection: {
type: String,
default: null,
},
});
const emit = defineEmits(['updateSeats']);
const { t } = useI18n();
const MAX_SEATS = 999;
const inputSeats = ref(props.currentSeats);
const isEditing = ref(false);
const updatingViaButtons = ref(false);
// Sync input with prop changes
watch(
() => props.currentSeats,
newValue => {
inputSeats.value = newValue;
isEditing.value = false;
updatingViaButtons.value = false;
}
);
const canDecreaseSeats = computed(() => {
return props.currentSeats > props.minSeats;
});
const canIncreaseSeats = computed(() => {
return props.currentSeats < MAX_SEATS;
});
const isValidInput = computed(() => {
const numValue = parseInt(inputSeats.value, 10);
if (Number.isNaN(numValue)) return false;
return numValue >= props.minSeats && numValue <= MAX_SEATS;
});
const hasChanges = computed(() => {
const numValue = parseInt(inputSeats.value, 10);
if (Number.isNaN(numValue)) return false;
return numValue !== props.currentSeats;
});
const canUpdate = computed(() => {
return isEditing.value && hasChanges.value && isValidInput.value;
});
const handleInputFocus = () => {
isEditing.value = true;
};
const handleInputChange = event => {
inputSeats.value = event.target.value;
isEditing.value = true;
};
const handleInputBlur = () => {
// Don't reset if currently updating
if (props.isUpdatingSeats) return;
// Always reset to current seats on blur
inputSeats.value = props.currentSeats;
// Hide update button and show +/- buttons
isEditing.value = false;
};
const handleUpdate = () => {
if (!isValidInput.value || !hasChanges.value) return;
const numValue = parseInt(inputSeats.value, 10);
const direction = numValue > props.currentSeats ? 'increase' : 'decrease';
emit('updateSeats', {
quantity: numValue,
direction,
});
};
const handleDecreaseSeats = () => {
if (canDecreaseSeats.value && !props.isUpdatingSeats) {
// Mark that update is via buttons, not manual input
isEditing.value = false;
updatingViaButtons.value = true;
emit('updateSeats', {
quantity: props.currentSeats - 1,
direction: 'decrease',
});
}
};
const handleIncreaseSeats = () => {
if (canIncreaseSeats.value && !props.isUpdatingSeats) {
// Mark that update is via buttons, not manual input
isEditing.value = false;
updatingViaButtons.value = true;
emit('updateSeats', {
quantity: props.currentSeats + 1,
direction: 'increase',
});
}
};
</script>
<template>
<div
class="flex items-center justify-between p-2.5 rounded-lg bg-n-slate-1 outline outline-1 outline-n-weak dark:bg-n-solid-1"
>
<div class="flex items-center gap-3">
<div
class="flex items-center justify-center size-9 rounded-md bg-n-slate-4"
>
<Icon icon="i-lucide-users" class="text-n-slate-11" />
</div>
<div>
<h6 class="text-sm font-semibold text-n-slate-12">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.NUMBER_OF_SEATS') }}
</h6>
<p class="text-xs text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.ADJUST_SEATS_HINT') }}
</p>
</div>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<Button
v-if="!isEditing"
faded
slate
sm
icon="i-lucide-minus"
type="button"
:is-loading="
isUpdatingSeats &&
updatingViaButtons &&
updatingDirection === 'decrease'
"
:disabled="!canDecreaseSeats || isUpdatingSeats"
@click="handleDecreaseSeats"
/>
<Input
v-model="inputSeats"
type="number"
:min="String(minSeats)"
:max="String(MAX_SEATS)"
:disabled="isUpdatingSeats"
class="w-20 text-center text-lg [&>input]:!py-1 [&>input]:!h-8"
@focus="handleInputFocus"
@input="handleInputChange"
@blur="handleInputBlur"
/>
<Button
v-if="!isEditing"
faded
slate
sm
icon="i-lucide-plus"
type="button"
:is-loading="
isUpdatingSeats &&
updatingViaButtons &&
updatingDirection === 'increase'
"
:disabled="!canIncreaseSeats || isUpdatingSeats"
@click="handleIncreaseSeats"
/>
<Button
v-if="isEditing"
color="blue"
size="sm"
:disabled="!canUpdate || isUpdatingSeats"
:is-loading="isUpdatingSeats"
@mousedown.prevent
@click="handleUpdate"
>
{{ t('BILLING_SETTINGS_V2.PLAN_SUMMARY.UPDATE') }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,165 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
plan: {
type: Object,
default: null,
},
isSubscribing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['subscribe']);
const { t } = useI18n();
const dialogRef = ref(null);
const quantity = ref(props.plan?.min_seats || 1);
const minSeats = computed(() => props.plan?.min_seats || 1);
const maxSeats = computed(() => props.plan?.max_seats || 999);
const totalPrice = computed(() => {
return (props.plan?.base_price || 0) * quantity.value;
});
const totalCredits = computed(() => {
// Credits are constant per plan, not multiplied by seats
return props.plan?.included_credits || 0;
});
const formatPrice = price => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
};
const formatNumber = num => {
return new Intl.NumberFormat('en-US').format(num);
};
const incrementQuantity = () => {
if (quantity.value < maxSeats.value) {
quantity.value += 1;
}
};
const decrementQuantity = () => {
if (quantity.value > minSeats.value) {
quantity.value -= 1;
}
};
const handleSubscribe = () => {
emit('subscribe', {
pricing_plan_id: props.plan.id,
quantity: quantity.value,
});
};
defineExpose({
dialogRef,
});
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
width="xl"
:title="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.TITLE')"
:description="
t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.DESCRIPTION', {
planName: plan?.name || '',
planPrice: formatPrice(plan?.base_price || 0),
credits: formatNumber(totalCredits || 0),
})
"
:confirm-button-label="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CONFIRM')"
:cancel-button-label="t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.CANCEL')"
:is-loading="isSubscribing"
@confirm="handleSubscribe"
>
<div v-if="plan" class="space-y-4">
<div
class="p-3 rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border border-n-slate-4 dark:border-n-solid-4 flex items-center justify-between"
>
<div>
<label class="block text-sm font-semibold text-n-slate-12">
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.NUMBER_OF_SEATS') }}
</label>
<p class="mt-0.5 text-xs text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MIN') }}: {{ minSeats }}
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MAX') }}:
{{ maxSeats }}
</p>
</div>
<div class="flex items-center gap-2">
<Button
type="button"
variant="faded"
color="slate"
size="sm"
icon="i-lucide-minus"
:disabled="quantity <= minSeats"
@click="decrementQuantity"
/>
<div class="relative">
<Input
v-model="quantity"
type="number"
:min="String(minSeats)"
:max="String(maxSeats)"
custom-input-class="w-16 text-center font-semibold text-base"
size="sm"
/>
</div>
<Button
type="button"
variant="faded"
color="slate"
size="sm"
icon="i-lucide-plus"
:disabled="quantity >= maxSeats"
@click="incrementQuantity"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.BASE_PRICE') }}
</span>
<span class="font-semibold text-n-slate-12">
{{ formatPrice(plan.base_price) }} {{ '×' }} {{ quantity }}
</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.INCLUDED_CREDITS') }}
</span>
<span class="font-semibold">
{{ formatNumber(totalCredits) }}
</span>
</div>
<div class="flex justify-between text-sm pt-2 border-t border-n-weak">
<span class="font-semibold text-n-slate-12">
{{ t('BILLING_SETTINGS_V2.SUBSCRIBE_MODAL.MONTHLY_TOTAL') }}
</span>
<span class="font-bold">
{{ formatPrice(totalPrice) }}
</span>
</div>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,96 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
option: {
type: Object,
required: true,
},
isProcessing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['topup']);
const { t } = useI18n();
const dialogRef = ref(null);
const formatPrice = price => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
};
const formatNumber = num => {
return new Intl.NumberFormat('en-US').format(num);
};
const handleTopup = () => {
emit('topup', {
credits: props.option.credits,
});
};
const open = () => {
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
width="sm"
:title="t('BILLING_SETTINGS_V2.TOPUP_MODAL.TITLE')"
:description="
t('BILLING_SETTINGS_V2.TOPUP_MODAL.DESCRIPTION', {
credits: formatNumber(option.credits),
price: formatPrice(option.price),
})
"
:confirm-button-label="t('BILLING_SETTINGS_V2.TOPUP_MODAL.CONFIRM')"
:cancel-button-label="t('BILLING_SETTINGS_V2.TOPUP_MODAL.CANCEL')"
:is-loading="isProcessing"
@confirm="handleTopup"
>
<div class="space-y-4">
<div
class="px-3 py-2 rounded-lg bg-n-slate-2 dark:bg-n-solid-2 space-y-2"
>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.TOPUP_MODAL.CREDITS') }}
</span>
<span class="text-lg font-bold text-n-slate-12">
{{ formatNumber(option.credits) }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.TOPUP_MODAL.PRICE') }}
</span>
<span class="text-lg font-bold text-n-slate-12">
{{ formatPrice(option.price) }}
</span>
</div>
<div
v-if="option.description"
class="pt-3 border-t border-n-weak text-sm text-n-slate-11"
>
{{ option.description }}
</div>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,135 @@
<script setup>
import { ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store.js';
import { useAlert } from 'dashboard/composables';
import Button from 'next/button/Button.vue';
import TopupDialog from './TopupDialog.vue';
defineProps({
options: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
isProcessing: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const store = useStore();
const topupDialogRef = ref(null);
const selectedOption = ref(null);
const formatPrice = price => {
if (!price) return '$0';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
};
const formatNumber = num => {
return new Intl.NumberFormat('en-US').format(num);
};
const openTopupDialog = async option => {
selectedOption.value = option;
await nextTick();
topupDialogRef.value?.open();
};
const handleTopup = async data => {
const result = await store.dispatch('accounts/v2Topup', data);
if (result.success) {
topupDialogRef.value?.close();
setTimeout(async () => {
await store.dispatch('accounts/fetchCreditsBalance');
}, 2000);
useAlert(t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.TOPUP_SUCCESS'));
} else if (result.error) {
topupDialogRef.value?.close();
if (result.error.includes('payment method')) {
useAlert(t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.NO_PAYMENT_METHOD_ERROR'));
} else {
useAlert(result.error);
}
}
};
</script>
<template>
<div
class="mt-4 grid grid-cols-3 gap-5 p-4 dark:bg-n-solid-1 bg-n-slate-2 rounded-lg"
>
<div class="col-span-1">
<h6 class="text-base font-semibold mb-2">
{{ t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.TITLE') }}
</h6>
<p class="col-span-2 text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.DESCRIPTION') }}
</p>
<p class="col-span-2 text-n-slate-11">
{{ t('BILLING_SETTINGS_V2.TOPUP_MODAL.INFO') }}
</p>
</div>
<div class="grid grid-cols-2 col-span-2 gap-3 group">
<div
v-for="option in options"
:key="option.id"
class="p-4 bg-n-background group-hover:opacity-50 hover:!opacity-100 shadow-sm outline outline-1 outline-n-weak rounded-lg transition-all cursor-pointer"
@click="openTopupDialog(option)"
>
<div class="flex gap-8">
<div>
<div class="text-xs tracking-wide">
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.CREDITS') }}
</div>
<div class="text-lg font-semibold">
{{ formatNumber(option.credits) }}
</div>
</div>
<div>
<div class="text-xs tracking-wide">
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.PRICE') }}
</div>
<div class="text-lg font-semibold">
{{ formatPrice(option.price) }}
</div>
</div>
</div>
<Button
class="w-full mt-4"
sm
slate
faded
@click.stop="openTopupDialog(option)"
>
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.BUY_NOW') }}
</Button>
</div>
<div v-if="options.length === 0 && !isLoading" class="py-8 text-center">
<p class="text-sm">
{{ $t('BILLING_SETTINGS_V2.TOPUP_OPTIONS.NO_OPTIONS') }}
</p>
</div>
</div>
<TopupDialog
v-if="selectedOption"
ref="topupDialogRef"
:option="selectedOption"
:is-processing="isProcessing"
@topup="handleTopup"
/>
</div>
</template>
@@ -0,0 +1,103 @@
<script setup>
/**
* BillingWrapper Component
*
* This component handles the conditional display of billing UI versions.
*
* Logic Flow:
* 1. If stripe_billing_version === 2 AND stripe_customer_id exists → Show V2 Billing UI
* 2. If stripe_billing_version === 2 BUT stripe_customer_id is null → Create customer via subscription API, then refresh
* 3. All other cases (stripe_billing_version !== 2 or null) → Show V1 Billing UI
*
* The component ensures backward compatibility by defaulting to V1 billing
* for all accounts that haven't been migrated to V2.
*/
import { computed, onMounted, ref } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import sessionStorage from 'shared/helpers/sessionStorage';
import IndexV2 from '../billing-v2/IndexV2.vue';
import Index from './Index.vue';
const store = useStore();
const { currentAccount } = useAccount();
const BILLING_CUSTOMER_CREATION_ATTEMPTED = 'billing_customer_creation_attempted';
const isCreatingCustomer = ref(false);
const customAttributes = computed(() => {
return currentAccount.value?.custom_attributes || {};
});
const stripeCustomerId = computed(() => {
return customAttributes.value.stripe_customer_id;
});
const stripeBillingVersion = computed(() => {
return customAttributes.value.stripe_billing_version;
});
/**
* Determines if V2 billing UI should be displayed
* Conditions:
* 1. stripe_billing_version must equal 2
* 2. stripe_customer_id must not be null
*/
const shouldShowV2Billing = computed(() => {
return stripeBillingVersion.value === 2 && !!stripeCustomerId.value;
});
/**
* Creates Stripe customer if not exists
* This is called when stripe_customer_id is null
*/
const createStripeCustomer = async () => {
isCreatingCustomer.value = true;
try {
await store.dispatch('accounts/subscription');
// Show alert asking user to refresh the page
useAlert('Stripe customer setup initiated. Please refresh the page to continue.');
// Auto refresh after 3 seconds
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
useAlert('Failed to create Stripe customer. Please try again later.');
console.error('Error creating Stripe customer:', error);
} finally {
isCreatingCustomer.value = false;
}
};
onMounted(async () => {
// Check if we need to create Stripe customer for V2 billing
if (stripeBillingVersion.value === 2 && !stripeCustomerId.value) {
const customerCreationAttempted = sessionStorage.get(BILLING_CUSTOMER_CREATION_ATTEMPTED);
// Only attempt once per session to avoid infinite loops
if (!customerCreationAttempted) {
sessionStorage.set(BILLING_CUSTOMER_CREATION_ATTEMPTED, true);
await createStripeCustomer();
}
}
});
</script>
<template>
<div v-if="isCreatingCustomer" class="flex items-center justify-center p-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-woot-500 mx-auto mb-4"></div>
<p class="text-slate-600">Setting up your billing account...</p>
<p class="text-sm text-slate-500 mt-2">This will only take a moment.</p>
</div>
</div>
<!-- Show V2 Billing UI when conditions are met -->
<IndexV2 v-else-if="shouldShowV2Billing" />
<!-- Show V1 Billing UI as fallback -->
<Index v-else />
</template>
@@ -1,7 +1,7 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import BillingWrapper from './BillingWrapper.vue';
export default {
routes: [
@@ -21,7 +21,7 @@ export default {
{
path: '',
name: 'billing_settings_index',
component: Index,
component: BillingWrapper,
meta: {
installationTypes: [INSTALLATION_TYPES.CLOUD],
permissions: ['administrator'],
@@ -14,9 +14,9 @@ defineProps({
<template>
<div class="grid grid-cols-[1fr_auto] gap-5">
<div>
<span class="text-base font-medium text-n-slate-12">
<h6 class="text-base font-semibold text-n-slate-12">
{{ title }}
</span>
</h6>
<p class="text-sm mt-1 text-n-slate-11">
{{ description }}
</p>
@@ -19,6 +19,21 @@ const state = {
isUpdating: false,
isCheckoutInProcess: false,
},
v2Billing: {
creditsBalance: null,
creditGrants: [],
pricingPlans: [],
topupOptions: [],
uiFlags: {
isFetchingBalance: false,
isFetchingGrants: false,
isFetchingPlans: false,
isFetchingTopupOptions: false,
isTopupInProcess: false,
isSubscribeInProcess: false,
isCancelInProcess: false,
},
},
};
export const getters = {
@@ -28,6 +43,12 @@ export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getV2BillingData($state) {
return $state.v2Billing;
},
getV2BillingUIFlags($state) {
return $state.v2Billing.uiFlags;
},
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
@@ -152,6 +173,200 @@ export const actions = {
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
// V2 Billing actions
fetchCreditsBalance: async ({ commit }) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingBalance: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
const responses = response.data?.limits?.captain?.responses || {};
// Transform captain.responses to the format expected by the UI
const creditsBalance = {
total_credits: responses.total_count || 0,
monthly_credits: responses.monthly || 0,
topup_credits: responses.topup || 0,
usage_this_month: responses.consumed || 0,
current_available: responses.current_available || 0,
};
commit(types.default.SET_CREDITS_BALANCE, creditsBalance);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isFetchingBalance: false,
});
}
},
fetchCreditGrants: async ({ commit }) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingGrants: true });
try {
const response = await EnterpriseAccountAPI.creditGrants();
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isFetchingGrants: false,
});
}
},
fetchV2PricingPlans: async ({ commit }) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingPlans: true });
try {
const response = await EnterpriseAccountAPI.v2PricingPlans();
commit(types.default.SET_V2_PRICING_PLANS, response.data.pricing_plans);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isFetchingPlans: false });
}
},
fetchV2TopupOptions: async ({ commit }) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isFetchingTopupOptions: true,
});
try {
const response = await EnterpriseAccountAPI.v2TopupOptions();
// Transform backend structure: rename 'amount' to 'price'
const transformedOptions = response.data.topup_options.map(option => ({
...option,
price: option.amount,
id: `topup-${option.credits}`,
}));
commit(types.default.SET_V2_TOPUP_OPTIONS, transformedOptions);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isFetchingTopupOptions: false,
});
}
},
v2Topup: async ({ commit }, data) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isTopupInProcess: true });
try {
await EnterpriseAccountAPI.v2Topup(data);
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.error || error.message;
return { success: false, error: errorMessage };
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isTopupInProcess: false });
}
},
v2Subscribe: async ({ commit, dispatch }, data) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: true,
});
try {
const response = await EnterpriseAccountAPI.v2Subscribe(data);
// If backend returns redirect_url, redirect to Stripe checkout
if (response.data.redirect_url) {
window.location.href = response.data.redirect_url;
return { success: true, redirecting: true };
}
// Otherwise refresh data (for non-Stripe flows)
await Promise.all([
dispatch('fetchCreditsBalance'),
dispatch('fetchV2PricingPlans'),
dispatch('subscription'),
]);
return { success: true };
} catch (error) {
throwErrorMessage(error);
return { success: false };
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: false,
});
}
},
v2CancelSubscription: async ({ commit, dispatch }, data) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, { isCancelInProcess: true });
try {
const response = await EnterpriseAccountAPI.cancelSubscription(data);
// Update account with new subscription status
if (response.data.id && response.data.custom_attributes) {
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
}
// Refresh balance and plans after cancellation
await Promise.all([
dispatch('fetchCreditsBalance'),
dispatch('fetchV2PricingPlans'),
]);
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.error || error.message;
return { success: false, error: errorMessage };
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isCancelInProcess: false,
});
}
},
v2UpdateQuantity: async (
{ commit, dispatch },
{ pricingPlanId, quantity }
) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: true,
});
try {
const response = await EnterpriseAccountAPI.changePricingPlan({
pricing_plan_id: pricingPlanId,
quantity,
});
// Update account with new quantity
if (response.data.id && response.data.custom_attributes) {
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
}
// Refresh balance after quantity update
await dispatch('fetchCreditsBalance');
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.error || error.message;
return { success: false, error: errorMessage };
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: false,
});
}
},
v2ChangePlan: async ({ commit, dispatch }, data) => {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: true,
});
try {
const response = await EnterpriseAccountAPI.changePricingPlan(data);
// Update account with new plan
if (response.data.id && response.data.custom_attributes) {
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
}
// Refresh balance and plans after change
await Promise.all([
dispatch('fetchCreditsBalance'),
dispatch('fetchV2PricingPlans'),
]);
return { success: true };
} catch (error) {
const errorMessage = error.response?.data?.error || error.message;
return { success: false, error: errorMessage };
} finally {
commit(types.default.SET_V2_BILLING_UI_FLAG, {
isSubscribeInProcess: false,
});
}
},
};
export const mutations = {
@@ -164,6 +379,26 @@ export const mutations = {
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
// V2 Billing mutations
[types.default.SET_V2_BILLING_UI_FLAG]($state, data) {
$state.v2Billing.uiFlags = {
...$state.v2Billing.uiFlags,
...data,
};
},
[types.default.SET_CREDITS_BALANCE]($state, data) {
$state.v2Billing.creditsBalance = data;
},
[types.default.SET_CREDIT_GRANTS]($state, data) {
$state.v2Billing.creditGrants = data;
},
[types.default.SET_V2_PRICING_PLANS]($state, data) {
$state.v2Billing.pricingPlans = data;
},
[types.default.SET_V2_TOPUP_OPTIONS]($state, data) {
$state.v2Billing.topupOptions = data;
},
};
export default {
@@ -84,6 +84,13 @@ export default {
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
DELETE_ACCOUNT: 'DELETE_AGENT',
// V2 Billing
SET_V2_BILLING_UI_FLAG: 'SET_V2_BILLING_UI_FLAG',
SET_CREDITS_BALANCE: 'SET_CREDITS_BALANCE',
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
SET_V2_PRICING_PLANS: 'SET_V2_PRICING_PLANS',
SET_V2_TOPUP_OPTIONS: 'SET_V2_TOPUP_OPTIONS',
// Agent
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',
SET_AGENT_CREATING_STATUS: 'SET_AGENT_CREATING_STATUS',
+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
@@ -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?
+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
+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
@@ -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,138 @@
class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService
# Common service for creating invoices with line items and charging immediately
# Used by both TopupService and ChangePlanService
#
# @param line_items [Array<Hash>] Array of line items: [{ amount:, description:, metadata: }]
# @param description [String] Description for the invoice
# @param currency [String] Currency code (default: 'usd')
# @param metadata [Hash] Metadata for the invoice
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
# Validate that customer has a default payment method
# If no default is set but payment methods exist, automatically set the first one as default
# @return [Hash, nil] Returns error hash if validation fails, nil if success
def validate_payment_method
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(stripe_customer_id)
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
# No default payment method found - try to set one automatically
ensure_default_payment_method_result = ensure_default_payment_method(customer)
return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil?
end
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to check payment method: #{e.message}")
{ success: false, message: "Error validating payment method: #{e.message}" }
end
# Ensure a default payment method is set for the customer
# If payment methods exist but none is default, set the first one as default
# @param customer [Stripe::Customer] The Stripe customer object
# @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully
def ensure_default_payment_method(customer)
payment_methods = fetch_customer_payment_methods(customer.id)
return no_payment_methods_error if payment_methods.data.empty?
set_first_payment_method_as_default(customer.id, payment_methods.data.first)
nil
rescue Stripe::StripeError => e
Rails.logger.error("Failed to set default payment method: #{e.message}")
{ success: false, message: "Error setting default payment method: #{e.message}" }
end
def fetch_customer_payment_methods(customer_id)
Stripe::PaymentMethod.list(customer: customer_id, limit: 100)
end
def no_payment_methods_error
{
success: false,
message: 'No payment methods found. Please add a payment method before making a purchase.'
}
end
def set_first_payment_method_as_default(customer_id, payment_method)
Stripe::Customer.update(
customer_id,
invoice_settings: { default_payment_method: payment_method.id }
)
Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}")
end
# Create invoice with line items and charge immediately
#
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
# @param description [String] Invoice description
# @param currency [String] Currency (default: 'usd')
# @param metadata [Hash] Invoice metadata
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
finalize_and_pay_invoice(invoice.id)
rescue Stripe::StripeError => e
Rails.logger.error("Error creating invoice: #{e.message}")
{ success: false, message: "Error creating invoice: #{e.message}" }
end
private
def create_invoice(customer_id, currency, description, metadata)
Stripe::Invoice.create({
customer: customer_id,
currency: currency,
collection_method: 'charge_automatically',
auto_advance: false,
description: description,
metadata: metadata.stringify_keys
})
end
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
line_items.each do |item|
Stripe::InvoiceItem.create({
customer: customer_id,
amount: item[:amount],
currency: currency,
invoice: invoice_id,
description: item[:description],
metadata: (item[:metadata] || {}).stringify_keys
})
end
end
# Finalize invoice and pay it immediately
# @param invoice_id [String] Stripe invoice ID
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
def finalize_and_pay_invoice(invoice_id)
# Finalize the invoice
finalized_invoice = Stripe::Invoice.finalize_invoice(
invoice_id,
{ auto_advance: false }
)
# Pay the invoice immediately if not already paid
if finalized_invoice.status == 'paid'
build_invoice_response(finalized_invoice)
else
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
build_invoice_response(paid_invoice)
end
rescue Stripe::StripeError => e
Rails.logger.error("Error finalizing/paying invoice: #{e.message}")
{ success: false, message: "Error processing payment: #{e.message}" }
end
def build_invoice_response(invoice)
{
success: true,
invoice_id: invoice.id,
invoice_url: invoice.hosted_invoice_url,
amount: invoice.total / 100.0,
status: invoice.status
}
end
end
@@ -0,0 +1,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
@@ -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)
@@ -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,138 @@
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
context 'when customer has a default payment method' do
it 'creates topup successfully' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
expect(result[:amount]).to eq(50.0)
end
end
context 'when customer has no default payment method but has payment methods available' do
let(:payment_methods_list) { OpenStruct.new(data: [OpenStruct.new(id: 'pm_first')]) }
before do
allow(Stripe::Customer).to receive(:retrieve).and_return(
OpenStruct.new(
id: 'cus_123',
invoice_settings: OpenStruct.new(default_payment_method: nil),
default_source: nil
)
)
allow(Stripe::PaymentMethod).to receive(:list).and_return(payment_methods_list)
allow(Stripe::Customer).to receive(:update)
end
it 'automatically sets default payment method and creates topup' do
result = service.create_topup(credits: 500)
expect(Stripe::Customer).to have_received(:update).with(
'cus_123',
invoice_settings: { default_payment_method: 'pm_first' }
)
expect(result[:success]).to be true
expect(result[:credits]).to eq(500)
end
end
context 'when customer has no payment methods at all' do
let(:empty_payment_methods) { OpenStruct.new(data: []) }
before do
allow(Stripe::Customer).to receive(:retrieve).and_return(
OpenStruct.new(
id: 'cus_123',
invoice_settings: OpenStruct.new(default_payment_method: nil),
default_source: nil
)
)
allow(Stripe::PaymentMethod).to receive(:list).and_return(empty_payment_methods)
end
it 'returns an error' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be false
expect(result[:message]).to include('No payment methods found')
end
end
context 'when on Hacker plan' do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Hacker' }) }
it 'returns an error' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be false
expect(result[:message]).to include('only available for Startup, Business, and Enterprise plans')
end
end
context 'when no plan is set' do
let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) }
it 'returns an error' do
result = service.create_topup(credits: 500)
expect(result[:success]).to be false
expect(result[:message]).to include('only available for Startup, Business, and Enterprise plans')
end
end
context 'with invalid topup amount' do
it 'returns an error for zero credits' do
result = service.create_topup(credits: 0)
expect(result[:success]).to be false
expect(result[:message]).to eq('Invalid topup amount')
end
it 'returns an error for negative credits' do
result = service.create_topup(credits: -100)
expect(result[:success]).to be false
expect(result[:message]).to eq('Invalid topup amount')
end
end
context 'when topup amount is not supported' do
before do
allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(999).and_return(nil)
end
it 'returns an error' do
result = service.create_topup(credits: 999)
expect(result[:success]).to be false
expect(result[:message]).to eq('Unsupported topup amount')
end
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