Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf74a0a58 | ||
|
|
dc0764b714 | ||
|
|
08abbd6ecd | ||
|
|
a1a31e50ad | ||
|
|
ae332b7d8d | ||
|
|
64371258b4 | ||
|
|
79890710bd | ||
|
|
f481627032 | ||
|
|
afa7f2d325 | ||
|
|
91bfc47f01 | ||
|
|
862e33e7b9 | ||
|
|
7d2307d343 | ||
|
|
69b818896a | ||
|
|
01d17674e6 | ||
|
|
689525ce85 | ||
|
|
6abecdfd43 | ||
|
|
7ac8e857c7 | ||
|
|
418d9cce27 | ||
|
|
eba12567e9 | ||
|
|
d230bb68d0 | ||
|
|
d3bbfd5c55 | ||
|
|
7babcfe622 | ||
|
|
aac3f9a332 | ||
|
|
f31666f877 | ||
|
|
3055a7c6f0 | ||
|
|
1e11f271f0 | ||
|
|
8d702ad299 | ||
|
|
4c1a047c94 | ||
|
|
c853d07f02 |
@@ -162,7 +162,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
@@ -928,7 +928,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)
|
||||
@@ -1139,7 +1139,7 @@ DEPENDENCIES
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
stripe
|
||||
stripe (= 17.2.0.pre.alpha.2)
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
|
||||
@@ -8,7 +8,10 @@ module BillingHelper
|
||||
# Return false if not plans are configured, so that no checks are enforced
|
||||
return false if default_plan.blank?
|
||||
|
||||
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
|
||||
# Handle both string and hash formats for default_plan
|
||||
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
|
||||
|
||||
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
|
||||
end
|
||||
|
||||
def conversations_this_month(account)
|
||||
|
||||
@@ -6,6 +6,7 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
super('', { accountScoped: true, enterprise: true });
|
||||
}
|
||||
|
||||
// V1 endpoints
|
||||
checkout() {
|
||||
return axios.post(`${this.url}checkout`);
|
||||
}
|
||||
@@ -23,6 +24,49 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
|
||||
// V2 Billing endpoints
|
||||
get v2BillingUrl() {
|
||||
const accountId = this.accountIdFromRoute;
|
||||
return `/enterprise/api/v2/accounts/${accountId}/billing`;
|
||||
}
|
||||
|
||||
getCreditGrants() {
|
||||
return axios.get(`${this.v2BillingUrl}/credit_grants`);
|
||||
}
|
||||
|
||||
getPricingPlans() {
|
||||
return axios.get(`${this.v2BillingUrl}/pricing_plans`);
|
||||
}
|
||||
|
||||
getTopupOptions() {
|
||||
return axios.get(`${this.v2BillingUrl}/topup_options`);
|
||||
}
|
||||
|
||||
topupCredits(credits) {
|
||||
return axios.post(`${this.v2BillingUrl}/topup`, { credits });
|
||||
}
|
||||
|
||||
subscribeToPlan(pricingPlanId, quantity) {
|
||||
return axios.post(`${this.v2BillingUrl}/subscribe`, {
|
||||
pricing_plan_id: pricingPlanId,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
|
||||
cancelSubscription(reason = null, feedback = null) {
|
||||
return axios.post(`${this.v2BillingUrl}/cancel_subscription`, {
|
||||
reason,
|
||||
feedback,
|
||||
});
|
||||
}
|
||||
|
||||
changePricingPlan(pricingPlanId, quantity) {
|
||||
return axios.post(`${this.v2BillingUrl}/change_pricing_plan`, {
|
||||
pricing_plan_id: pricingPlanId,
|
||||
quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -382,6 +382,8 @@
|
||||
"BILLING_SETTINGS": {
|
||||
"TITLE": "Billing",
|
||||
"DESCRIPTION": "Manage your subscription here, upgrade your plan and get more for your team.",
|
||||
"BILLING_PORTAL": "Billing Portal",
|
||||
"VIEW_ALL_PLANS": "View all plans",
|
||||
"CURRENT_PLAN": {
|
||||
"TITLE": "Current Plan",
|
||||
"PLAN_NOTE": "You are currently subscribed to the **{plan}** plan with **{quantity}** licenses",
|
||||
@@ -394,6 +396,25 @@
|
||||
"DESCRIPTION": "View your previous invoices, edit your billing details, or cancel your subscription.",
|
||||
"BUTTON_TXT": "Go to the billing portal"
|
||||
},
|
||||
"SUBSCRIPTION": {
|
||||
"TITLE": "Subscription",
|
||||
"DESCRIPTION": "Manage your current plan, seats, and billing cycle.",
|
||||
"CURRENT_PLAN": "Current plan",
|
||||
"NUMBER_OF_SEATS": "Number of seats",
|
||||
"RENEWS_ON": "Renews on",
|
||||
"ENDS_ON": "Ends on",
|
||||
"CANCELLED_ON": "Cancelled on",
|
||||
"CHANGE_PLAN": "Change",
|
||||
"CHANGE_SEATS": "Change",
|
||||
"CANCEL_SUBSCRIPTION": "Cancel"
|
||||
},
|
||||
"CAPTAIN_AI": {
|
||||
"TITLE": "Captain AI",
|
||||
"DESCRIPTION": "Purchase and manage credits for Captain AI features.",
|
||||
"AI_CREDITS": "AI credits",
|
||||
"PURCHASE_CREDITS": "Purchase credits",
|
||||
"VIEW_HISTORY": "View credit history"
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"TITLE": "Captain",
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
@@ -402,6 +423,78 @@
|
||||
"RESPONSES": "Responses",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
|
||||
},
|
||||
"CHANGE_PLAN_MODAL": {
|
||||
"TITLE": "Change plan",
|
||||
"DESCRIPTION": "Select a plan that best fits your needs.",
|
||||
"SUBSCRIBE_TITLE": "Choose a plan",
|
||||
"SUBSCRIBE_DESCRIPTION": "Select a plan to subscribe and unlock all features.",
|
||||
"CURRENT_PLAN": "Current plan",
|
||||
"CREDITS_MONTH": "{credits} credits/month",
|
||||
"PER_USER_MONTH": "/user/month",
|
||||
"AI_CREDITS_MONTH": "AI credits/month",
|
||||
"CANCEL": "Cancel",
|
||||
"SELECT_PLAN": "Select plan",
|
||||
"CHANGE_PLAN": "Change plan",
|
||||
"SUBSCRIBE": "Subscribe"
|
||||
},
|
||||
"CANCEL_MODAL": {
|
||||
"TITLE": "Cancel subscription",
|
||||
"WARNING_TITLE": "You're about to cancel your {plan} plan",
|
||||
"WARNING_DESCRIPTION": "Your subscription will remain active until {date}. After that, you'll lose access to:",
|
||||
"FEATURE_AI_CREDITS": "AI credits for Captain features",
|
||||
"FEATURE_TEAM_COLLABORATION": "Team collaboration tools",
|
||||
"FEATURE_PRIORITY_SUPPORT": "Priority customer support",
|
||||
"KEEP_SUBSCRIPTION": "Keep subscription",
|
||||
"CANCEL_SUBSCRIPTION": "Cancel subscription"
|
||||
},
|
||||
"FEEDBACK_MODAL": {
|
||||
"TITLE": "Help us improve",
|
||||
"DESCRIPTION": "We're sorry to see you go. Please help us improve by sharing your reason for cancelling.",
|
||||
"REASON_LABEL": "Why are you cancelling?",
|
||||
"REASONS": {
|
||||
"TOO_EXPENSIVE": "It's too expensive",
|
||||
"NOT_USING": "I'm not using it enough",
|
||||
"MISSING_FEATURES": "Missing features I need",
|
||||
"BETTER_ALTERNATIVE": "Found a better alternative",
|
||||
"TECHNICAL_ISSUES": "Technical issues",
|
||||
"OTHER": "Other reason"
|
||||
},
|
||||
"FEEDBACK_LABEL": "Additional feedback (optional)",
|
||||
"FEEDBACK_PLACEHOLDER": "Tell us more about your experience...",
|
||||
"GO_BACK": "Go back",
|
||||
"SUBMIT_CANCEL": "Submit and cancel"
|
||||
},
|
||||
"PURCHASE_MODAL": {
|
||||
"TITLE": "Purchase AI credits",
|
||||
"DESCRIPTION": "Choose a credit package that suits your needs.",
|
||||
"MOST_POPULAR": "Most popular",
|
||||
"CREDITS": "credits",
|
||||
"NOTE": "Credits will be added to your account immediately and are non-refundable. Unused credits expire after 12 months.",
|
||||
"CANCEL": "Cancel",
|
||||
"PURCHASE": "Purchase"
|
||||
},
|
||||
"CREDIT_HISTORY": {
|
||||
"TITLE": "Credit history",
|
||||
"DESCRIPTION": "View your credit purchases and allocations.",
|
||||
"NAME": "Name",
|
||||
"CREDITS": "Credits",
|
||||
"SOURCE": "Source",
|
||||
"EFFECTIVE_AT": "Effective at",
|
||||
"EXPIRES_AT": "Expires at",
|
||||
"NO_RECORDS": "No credit history found.",
|
||||
"CLOSE": "Close"
|
||||
},
|
||||
"HELP": {
|
||||
"TITLE": "Need help?",
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
"CHAT": "Chat with us"
|
||||
},
|
||||
"ALERTS": {
|
||||
"PLAN_CHANGED": "Your plan has been changed successfully.",
|
||||
"SEATS_UPDATED": "Number of seats updated successfully.",
|
||||
"SUBSCRIPTION_CANCELLED": "Your subscription has been scheduled for cancellation.",
|
||||
"CREDITS_PURCHASED": "Credits purchased successfully."
|
||||
},
|
||||
"CHAT_WITH_US": {
|
||||
"TITLE": "Need help?",
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
|
||||
@@ -1,124 +1,254 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { format } from 'date-fns';
|
||||
import sessionStorage from 'shared/helpers/sessionStorage';
|
||||
|
||||
import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import SubscriptionRow from './components/SubscriptionRow.vue';
|
||||
import SeatStepper from './components/SeatStepper.vue';
|
||||
import ChangePlanModal from './components/ChangePlanModal.vue';
|
||||
import CancelSubscriptionModal from './components/CancelSubscriptionModal.vue';
|
||||
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import CreditHistoryModal from './components/CreditHistoryModal.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { currentAccount, isOnChatwootCloud } = useAccount();
|
||||
const {
|
||||
captainEnabled,
|
||||
captainLimits,
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
fetchLimits: fetchCaptainLimits,
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const store = useStore();
|
||||
const pricingPlans = useMapGetter('accounts/getPricingPlans');
|
||||
const topupOptions = useMapGetter('accounts/getTopupOptions');
|
||||
const creditGrants = useMapGetter('accounts/getCreditGrants');
|
||||
|
||||
const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
// Modal refs
|
||||
const changePlanModalRef = ref(null);
|
||||
const cancelModalRef = ref(null);
|
||||
const purchaseCreditsModalRef = ref(null);
|
||||
const creditHistoryModalRef = ref(null);
|
||||
|
||||
// State
|
||||
const isWaitingForBilling = ref(false);
|
||||
const isEditingSeats = ref(false);
|
||||
const editedSeats = ref(0);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for plan name
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
const planName = computed(() => {
|
||||
return customAttributes.value.plan_name;
|
||||
});
|
||||
const planName = computed(() => customAttributes.value.plan_name);
|
||||
const currentPlanId = computed(
|
||||
() => customAttributes.value.stripe_pricing_plan_id
|
||||
);
|
||||
const subscriptionId = computed(
|
||||
() => customAttributes.value.stripe_subscription_id
|
||||
);
|
||||
const subscribedQuantity = computed(
|
||||
() => customAttributes.value.subscribed_quantity
|
||||
);
|
||||
const subscriptionStatus = computed(
|
||||
() => customAttributes.value.subscription_status
|
||||
);
|
||||
|
||||
/**
|
||||
* Computed property for subscribed quantity
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
const subscribedQuantity = computed(() => {
|
||||
return customAttributes.value.subscribed_quantity;
|
||||
});
|
||||
// Check if user has an active subscription (required for seat changes and cancellation)
|
||||
const hasActiveSubscription = computed(() => !!subscriptionId.value);
|
||||
|
||||
const subscriptionRenewsOn = computed(() => {
|
||||
if (!customAttributes.value.subscription_ends_on) return '';
|
||||
const endDate = new Date(customAttributes.value.subscription_ends_on);
|
||||
// return date as 12 Jan, 2034
|
||||
const subscriptionEndsAt = computed(() => {
|
||||
if (!customAttributes.value.subscription_ends_at) return '';
|
||||
const endDate = new Date(customAttributes.value.subscription_ends_at);
|
||||
return format(endDate, 'dd MMM, yyyy');
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property indicating if user has a billing plan
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const hasABillingPlan = computed(() => {
|
||||
return !!planName.value;
|
||||
const subscriptionCancelledAt = computed(() => {
|
||||
if (!customAttributes.value.subscription_cancelled_at) return '';
|
||||
const cancelledDate = new Date(
|
||||
customAttributes.value.subscription_cancelled_at
|
||||
);
|
||||
return format(cancelledDate, 'dd MMM, yyyy');
|
||||
});
|
||||
|
||||
const hasABillingPlan = computed(() => !!planName.value);
|
||||
|
||||
const isCancelled = computed(() => {
|
||||
return subscriptionStatus.value === 'cancel_at_period_end';
|
||||
});
|
||||
|
||||
// Captain credits
|
||||
const creditsUsed = computed(() => {
|
||||
if (!responseLimits.value) return 0;
|
||||
return responseLimits.value.consumed || 0;
|
||||
});
|
||||
|
||||
const creditsTotal = computed(() => {
|
||||
if (!responseLimits.value) return 0;
|
||||
return responseLimits.value.totalCount || 0;
|
||||
});
|
||||
|
||||
// Actions
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
// Always fetch captain limits to show AI credits
|
||||
fetchCaptainLimits();
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
// If self-hosted, redirect to dashboard
|
||||
if (!isOnChatwootCloud.value) {
|
||||
router.push({ name: 'home' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already attempted a refresh for billing setup
|
||||
const billingRefreshAttempted = sessionStorage.get(BILLING_REFRESH_ATTEMPTED);
|
||||
|
||||
// If cloud user, fetch account details first
|
||||
await fetchAccountDetails();
|
||||
|
||||
// If still no billing plan after fetch
|
||||
if (!hasABillingPlan.value) {
|
||||
// If we haven't attempted refresh yet, do it once
|
||||
if (!billingRefreshAttempted) {
|
||||
isWaitingForBilling.value = true;
|
||||
sessionStorage.set(BILLING_REFRESH_ATTEMPTED, true);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
} else {
|
||||
// We've already tried refreshing, so just show the no billing message
|
||||
// Clear the flag for future visits
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
} else {
|
||||
// Billing plan found, clear any existing refresh flag
|
||||
sessionStorage.remove(BILLING_REFRESH_ATTEMPTED);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickBillingPortal = () => {
|
||||
const openBillingPortal = () => {
|
||||
store.dispatch('accounts/checkout');
|
||||
};
|
||||
|
||||
const onToggleChatWindow = () => {
|
||||
const openChatWidget = () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const openChangePlanModal = async () => {
|
||||
await store.dispatch('accounts/fetchPricingPlans');
|
||||
changePlanModalRef.value?.open();
|
||||
};
|
||||
|
||||
const handlePlanSelect = async plan => {
|
||||
try {
|
||||
// If no active subscription, use subscribe API (for Hacker plan users)
|
||||
if (!hasActiveSubscription.value) {
|
||||
await store.dispatch('accounts/subscribeToPlan', {
|
||||
pricingPlanId: plan.id,
|
||||
quantity: 1,
|
||||
});
|
||||
// subscribeToPlan redirects to Stripe, so no need to close modal
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise use change plan API (for existing subscribers)
|
||||
await store.dispatch('accounts/changePricingPlan', {
|
||||
pricingPlanId: plan.id,
|
||||
quantity: subscribedQuantity.value || 1,
|
||||
});
|
||||
changePlanModalRef.value?.close();
|
||||
useAlert(t('BILLING_SETTINGS.ALERTS.PLAN_CHANGED'));
|
||||
await store.dispatch('accounts/subscription');
|
||||
} catch {
|
||||
// Error already handled
|
||||
}
|
||||
};
|
||||
|
||||
// Seats editing
|
||||
const startEditingSeats = () => {
|
||||
editedSeats.value = subscribedQuantity.value || 1;
|
||||
isEditingSeats.value = true;
|
||||
};
|
||||
|
||||
const cancelEditingSeats = () => {
|
||||
isEditingSeats.value = false;
|
||||
};
|
||||
|
||||
const saveSeats = async newSeats => {
|
||||
if (newSeats === subscribedQuantity.value) {
|
||||
isEditingSeats.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch('accounts/changePricingPlan', {
|
||||
pricingPlanId: currentPlanId.value,
|
||||
quantity: newSeats,
|
||||
});
|
||||
isEditingSeats.value = false;
|
||||
useAlert(t('BILLING_SETTINGS.ALERTS.SEATS_UPDATED'));
|
||||
await store.dispatch('accounts/subscription');
|
||||
} catch {
|
||||
// Error already handled
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel subscription
|
||||
const openCancelModal = () => {
|
||||
cancelModalRef.value?.open();
|
||||
};
|
||||
|
||||
const handleCancelConfirm = async ({ reason, feedback }) => {
|
||||
try {
|
||||
await store.dispatch('accounts/cancelAccountSubscription', {
|
||||
reason,
|
||||
feedback,
|
||||
});
|
||||
cancelModalRef.value?.close();
|
||||
useAlert(t('BILLING_SETTINGS.ALERTS.SUBSCRIPTION_CANCELLED'));
|
||||
await store.dispatch('accounts/subscription');
|
||||
} catch {
|
||||
// Error already handled
|
||||
}
|
||||
};
|
||||
|
||||
// Purchase credits
|
||||
const openPurchaseCreditsModal = async () => {
|
||||
await store.dispatch('accounts/fetchTopupOptions');
|
||||
purchaseCreditsModalRef.value?.open();
|
||||
};
|
||||
|
||||
const handlePurchaseCredits = async option => {
|
||||
try {
|
||||
await store.dispatch('accounts/purchaseCredits', {
|
||||
credits: option.credits,
|
||||
});
|
||||
purchaseCreditsModalRef.value?.close();
|
||||
useAlert(t('BILLING_SETTINGS.ALERTS.CREDITS_PURCHASED'));
|
||||
fetchCaptainLimits();
|
||||
} catch {
|
||||
// Error already handled
|
||||
}
|
||||
};
|
||||
|
||||
// Credit history
|
||||
const openCreditHistoryModal = async () => {
|
||||
await store.dispatch('accounts/fetchCreditGrants');
|
||||
creditHistoryModalRef.value?.open();
|
||||
};
|
||||
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
@@ -137,92 +267,188 @@ onMounted(handleBillingPageLogic);
|
||||
<BaseSettingsHeader
|
||||
:title="$t('BILLING_SETTINGS.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('BILLING_SETTINGS.VIEW_PRICING')"
|
||||
feature-name="billing"
|
||||
/>
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
solid
|
||||
blue
|
||||
sm
|
||||
:label="$t('BILLING_SETTINGS.BILLING_PORTAL')"
|
||||
:is-loading="uiFlags.isCheckoutInProcess"
|
||||
@click="openBillingPortal"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<section class="grid gap-4">
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.DESCRIPTION')"
|
||||
<section class="flex flex-col gap-6">
|
||||
<a
|
||||
href="https://www.chatwoot.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-n-blue-text hover:underline"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm solid blue @click="onClickBillingPortal">
|
||||
{{ $t('BILLING_SETTINGS.MANAGE_SUBSCRIPTION.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
</template>
|
||||
<div
|
||||
v-if="planName || subscribedQuantity || subscriptionRenewsOn"
|
||||
class="grid lg:grid-cols-4 sm:grid-cols-3 grid-cols-1 gap-2 divide-x divide-n-weak"
|
||||
>
|
||||
<DetailItem
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.TITLE')"
|
||||
{{ $t('BILLING_SETTINGS.VIEW_ALL_PLANS') }}
|
||||
<span class="i-lucide-chevron-right size-4" />
|
||||
</a>
|
||||
|
||||
<!-- Subscription Card -->
|
||||
<BillingCard
|
||||
:title="$t('BILLING_SETTINGS.SUBSCRIPTION.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.SUBSCRIPTION.DESCRIPTION')"
|
||||
>
|
||||
<div class="px-5">
|
||||
<SubscriptionRow
|
||||
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CURRENT_PLAN')"
|
||||
:value="planName"
|
||||
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_PLAN')"
|
||||
:action-disabled="isCancelled"
|
||||
@action="openChangePlanModal"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="subscribedQuantity"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.SEAT_COUNT')"
|
||||
|
||||
<SubscriptionRow
|
||||
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.NUMBER_OF_SEATS')"
|
||||
:value="subscribedQuantity"
|
||||
:action-text="$t('BILLING_SETTINGS.SUBSCRIPTION.CHANGE_SEATS')"
|
||||
:show-action="
|
||||
!isEditingSeats && !isCancelled && hasActiveSubscription
|
||||
"
|
||||
@action="startEditingSeats"
|
||||
>
|
||||
<template v-if="isEditingSeats" #value>
|
||||
<SeatStepper
|
||||
v-model="editedSeats"
|
||||
:min="1"
|
||||
:is-loading="uiFlags.isChangingPlan"
|
||||
@save="saveSeats"
|
||||
@cancel="cancelEditingSeats"
|
||||
/>
|
||||
</template>
|
||||
</SubscriptionRow>
|
||||
|
||||
<SubscriptionRow
|
||||
v-if="isCancelled"
|
||||
:label="$t('BILLING_SETTINGS.SUBSCRIPTION.CANCELLED_ON')"
|
||||
:value="subscriptionCancelledAt"
|
||||
/>
|
||||
<DetailItem
|
||||
v-if="subscriptionRenewsOn"
|
||||
:label="$t('BILLING_SETTINGS.CURRENT_PLAN.RENEWS_ON')"
|
||||
:value="subscriptionRenewsOn"
|
||||
|
||||
<SubscriptionRow
|
||||
v-if="hasActiveSubscription"
|
||||
:label="
|
||||
isCancelled
|
||||
? $t('BILLING_SETTINGS.SUBSCRIPTION.ENDS_ON')
|
||||
: $t('BILLING_SETTINGS.SUBSCRIPTION.RENEWS_ON')
|
||||
"
|
||||
:value="subscriptionEndsAt"
|
||||
:action-text="
|
||||
isCancelled
|
||||
? ''
|
||||
: $t('BILLING_SETTINGS.SUBSCRIPTION.CANCEL_SUBSCRIPTION')
|
||||
"
|
||||
@action="openCancelModal"
|
||||
/>
|
||||
</div>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Captain AI Card -->
|
||||
<BillingCard
|
||||
v-if="captainEnabled"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN_AI.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm faded slate disabled>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
sm
|
||||
:label="$t('BILLING_SETTINGS.CAPTAIN_AI.PURCHASE_CREDITS')"
|
||||
@click="openPurchaseCreditsModal"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="captainLimits && responseLimits" class="px-5">
|
||||
<div class="px-5">
|
||||
<BillingMeter
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.RESPONSES')"
|
||||
v-bind="responseLimits"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.AI_CREDITS')"
|
||||
:consumed="creditsUsed"
|
||||
:total-count="creditsTotal"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="captainLimits && documentLimits" class="px-5">
|
||||
<BillingMeter
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.DOCUMENTS')"
|
||||
v-bind="documentLimits"
|
||||
/>
|
||||
<div class="px-5 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 hover:underline"
|
||||
@click="openCreditHistoryModal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN_AI.VIEW_HISTORY') }}
|
||||
</button>
|
||||
</div>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Captain AI Upgrade Card (when not enabled) -->
|
||||
<BillingCard
|
||||
v-else
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN.TITLE')"
|
||||
:title="$t('BILLING_SETTINGS.CAPTAIN_AI.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.UPGRADE')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm solid slate @click="onClickBillingPortal">
|
||||
{{ $t('CAPTAIN.PAYWALL.UPGRADE_NOW') }}
|
||||
</ButtonV4>
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
sm
|
||||
:label="$t('CAPTAIN.PAYWALL.UPGRADE_NOW')"
|
||||
@click="openBillingPortal"
|
||||
/>
|
||||
</template>
|
||||
</BillingCard>
|
||||
|
||||
<!-- Help Section -->
|
||||
<BillingHeader
|
||||
class="px-1 mt-5"
|
||||
:title="$t('BILLING_SETTINGS.CHAT_WITH_US.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.CHAT_WITH_US.DESCRIPTION')"
|
||||
class="px-1 mt-2"
|
||||
:title="$t('BILLING_SETTINGS.HELP.TITLE')"
|
||||
:description="$t('BILLING_SETTINGS.HELP.DESCRIPTION')"
|
||||
>
|
||||
<ButtonV4
|
||||
sm
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@click="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
sm
|
||||
icon="i-lucide-message-circle"
|
||||
:label="$t('BILLING_SETTINGS.HELP.CHAT')"
|
||||
@click="openChatWidget"
|
||||
/>
|
||||
</BillingHeader>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<ChangePlanModal
|
||||
ref="changePlanModalRef"
|
||||
:plans="pricingPlans"
|
||||
:current-plan-id="currentPlanId"
|
||||
:is-loading="uiFlags.isChangingPlan || uiFlags.isSubscribing"
|
||||
:is-subscribe-mode="!hasActiveSubscription"
|
||||
@select="handlePlanSelect"
|
||||
/>
|
||||
|
||||
<CancelSubscriptionModal
|
||||
ref="cancelModalRef"
|
||||
:plan-name="planName"
|
||||
:renews-on="subscriptionEndsAt"
|
||||
:is-loading="uiFlags.isCancellingSubscription"
|
||||
@confirm="handleCancelConfirm"
|
||||
/>
|
||||
|
||||
<PurchaseCreditsModal
|
||||
ref="purchaseCreditsModalRef"
|
||||
:options="topupOptions"
|
||||
:is-loading="uiFlags.isPurchasingCredits"
|
||||
@purchase="handlePurchaseCredits"
|
||||
/>
|
||||
|
||||
<CreditHistoryModal
|
||||
ref="creditHistoryModalRef"
|
||||
:credit-grants="creditGrants"
|
||||
:is-loading="uiFlags.isFetchingCreditGrants"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CancellationFeedbackForm from './CancellationFeedbackForm.vue';
|
||||
|
||||
defineProps({
|
||||
planName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
renewsOn: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const step = ref('warning'); // 'warning' | 'feedback'
|
||||
|
||||
const features = [
|
||||
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_AI_CREDITS',
|
||||
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_TEAM_COLLABORATION',
|
||||
'BILLING_SETTINGS.CANCEL_MODAL.FEATURE_PRIORITY_SUPPORT',
|
||||
];
|
||||
|
||||
const handleProceedToFeedback = () => {
|
||||
step.value = 'feedback';
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
step.value = 'warning';
|
||||
};
|
||||
|
||||
const handleFeedbackSubmit = ({ reason, feedback }) => {
|
||||
emit('confirm', { reason, feedback });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
step.value = 'warning';
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
step.value = 'warning';
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="
|
||||
step === 'warning'
|
||||
? t('BILLING_SETTINGS.CANCEL_MODAL.TITLE')
|
||||
: t('BILLING_SETTINGS.FEEDBACK_MODAL.TITLE')
|
||||
"
|
||||
width="lg"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Warning Step -->
|
||||
<template v-if="step === 'warning'">
|
||||
<div class="p-4 border rounded-lg bg-n-ruby-9/5 border-n-ruby-9/20">
|
||||
<div class="flex gap-3">
|
||||
<span
|
||||
class="flex-shrink-0 i-lucide-alert-triangle size-5 text-n-ruby-9"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm font-medium text-n-ruby-11">
|
||||
{{
|
||||
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_TITLE', {
|
||||
plan: planName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-ruby-11/80">
|
||||
{{
|
||||
t('BILLING_SETTINGS.CANCEL_MODAL.WARNING_DESCRIPTION', {
|
||||
date: renewsOn,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2 text-sm text-n-ruby-11"
|
||||
>
|
||||
<span class="size-1.5 rounded-full bg-n-ruby-9" />
|
||||
{{ t(feature) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-6">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('BILLING_SETTINGS.CANCEL_MODAL.KEEP_SUBSCRIPTION')"
|
||||
class="flex-1"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
color="ruby"
|
||||
solid
|
||||
:label="t('BILLING_SETTINGS.CANCEL_MODAL.CANCEL_SUBSCRIPTION')"
|
||||
class="flex-1"
|
||||
@click="handleProceedToFeedback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Feedback Step -->
|
||||
<template v-else>
|
||||
<CancellationFeedbackForm
|
||||
:is-loading="isLoading"
|
||||
@submit="handleFeedbackSubmit"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedReason = ref('');
|
||||
const additionalFeedback = ref('');
|
||||
|
||||
const cancellationReasons = [
|
||||
{
|
||||
key: 'too_expensive',
|
||||
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TOO_EXPENSIVE',
|
||||
},
|
||||
{
|
||||
key: 'not_using',
|
||||
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.NOT_USING',
|
||||
},
|
||||
{
|
||||
key: 'missing_features',
|
||||
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.MISSING_FEATURES',
|
||||
},
|
||||
{
|
||||
key: 'better_alternative',
|
||||
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.BETTER_ALTERNATIVE',
|
||||
},
|
||||
{
|
||||
key: 'technical_issues',
|
||||
label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.TECHNICAL_ISSUES',
|
||||
},
|
||||
{ key: 'other', label: 'BILLING_SETTINGS.FEEDBACK_MODAL.REASONS.OTHER' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', {
|
||||
reason: selectedReason.value,
|
||||
feedback: additionalFeedback.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.REASON_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="reason in cancellationReasons"
|
||||
:key="reason.key"
|
||||
class="flex items-center gap-3 p-3 transition-colors border rounded-lg cursor-pointer border-n-weak hover:bg-n-alpha-1"
|
||||
:class="{
|
||||
'border-n-teal-9 bg-n-teal-9/5': selectedReason === reason.key,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
v-model="selectedReason"
|
||||
type="radio"
|
||||
name="cancellation-reason"
|
||||
:value="reason.key"
|
||||
class="w-4 h-4 accent-n-teal-9"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">{{ t(reason.label) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_LABEL') }}
|
||||
</label>
|
||||
<textarea
|
||||
v-model="additionalFeedback"
|
||||
rows="3"
|
||||
class="w-full p-3 text-sm transition-colors border rounded-lg resize-none bg-n-alpha-1 border-n-weak text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none focus:border-n-strong"
|
||||
:placeholder="t('BILLING_SETTINGS.FEEDBACK_MODAL.FEEDBACK_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors border rounded-lg border-n-weak text-n-slate-12 hover:bg-n-alpha-1"
|
||||
:disabled="isLoading"
|
||||
@click="handleBack"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.GO_BACK') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium text-white transition-colors rounded-lg bg-n-slate-9 hover:bg-n-slate-10 disabled:opacity-50"
|
||||
:disabled="!selectedReason || isLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.FEEDBACK_MODAL.SUBMIT_CANCEL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PlanCard from './PlanCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
plans: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentPlanId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSubscribeMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const selectedPlanId = ref(props.currentPlanId);
|
||||
|
||||
const selectedPlan = computed(() => {
|
||||
return filteredPlans.value.find(p => p.id === selectedPlanId.value);
|
||||
});
|
||||
|
||||
// Filter out Hacker plan when user already has a subscription (change plan mode)
|
||||
const filteredPlans = computed(() => {
|
||||
if (props.isSubscribeMode) {
|
||||
// In subscribe mode (no subscription), show all plans including Hacker
|
||||
return props.plans;
|
||||
}
|
||||
// In change plan mode (has subscription), hide Hacker plan
|
||||
return props.plans.filter(
|
||||
plan => plan.display_name?.toLowerCase() !== 'hacker'
|
||||
);
|
||||
});
|
||||
|
||||
const isCurrentPlanSelected = computed(() => {
|
||||
return selectedPlanId.value === props.currentPlanId;
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (props.isSubscribeMode) {
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_TITLE');
|
||||
}
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.TITLE');
|
||||
});
|
||||
|
||||
const modalDescription = computed(() => {
|
||||
if (props.isSubscribeMode) {
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE_DESCRIPTION');
|
||||
}
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.DESCRIPTION');
|
||||
});
|
||||
|
||||
const confirmButtonLabel = computed(() => {
|
||||
if (isCurrentPlanSelected.value) {
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN');
|
||||
}
|
||||
if (props.isSubscribeMode) {
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.SUBSCRIBE');
|
||||
}
|
||||
return t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CHANGE_PLAN');
|
||||
});
|
||||
|
||||
const handlePlanSelect = plan => {
|
||||
selectedPlanId.value = plan.id;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isCurrentPlanSelected.value && selectedPlan.value) {
|
||||
emit('select', selectedPlan.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
selectedPlanId.value = props.currentPlanId;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="modalTitle"
|
||||
:description="modalDescription"
|
||||
width="2xl"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<PlanCard
|
||||
v-for="plan in filteredPlans"
|
||||
:key="plan.id"
|
||||
:plan="plan"
|
||||
:is-selected="selectedPlanId === plan.id"
|
||||
:is-current="plan.id === currentPlanId"
|
||||
:is-disabled="isLoading || plan.id === currentPlanId"
|
||||
@select="handlePlanSelect"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CANCEL')"
|
||||
class="w-full"
|
||||
:disabled="isLoading"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
color="slate"
|
||||
solid
|
||||
:label="confirmButtonLabel"
|
||||
class="w-full"
|
||||
:disabled="isCurrentPlanSelected || isLoading"
|
||||
:is-loading="isLoading"
|
||||
@click="handleConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format } from 'date-fns';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
creditGrants: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const formattedGrants = computed(() => {
|
||||
return props.creditGrants.map(grant => ({
|
||||
...grant,
|
||||
formattedEffectiveAt: grant.effective_at
|
||||
? format(new Date(grant.effective_at), 'dd MMM, yyyy')
|
||||
: '-',
|
||||
formattedExpiresAt: grant.expires_at
|
||||
? format(new Date(grant.expires_at), 'dd MMM, yyyy')
|
||||
: '-',
|
||||
}));
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('BILLING_SETTINGS.CREDIT_HISTORY.TITLE')"
|
||||
:description="t('BILLING_SETTINGS.CREDIT_HISTORY.DESCRIPTION')"
|
||||
width="2xl"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
overflow-y-auto
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<span class="i-lucide-loader-2 size-6 animate-spin text-n-slate-11" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="formattedGrants.length === 0" class="py-8 text-center">
|
||||
<span
|
||||
class="i-lucide-credit-card size-12 text-n-slate-9 mx-auto block mb-3"
|
||||
/>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NO_RECORDS') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden border rounded-lg border-n-weak">
|
||||
<table class="w-full">
|
||||
<thead class="bg-n-alpha-1">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.NAME') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.CREDITS') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.SOURCE') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EFFECTIVE_AT') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CREDIT_HISTORY.EXPIRES_AT') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-weak">
|
||||
<tr v-for="grant in formattedGrants" :key="grant.id">
|
||||
<td class="px-4 py-3 text-sm text-n-slate-12">
|
||||
{{ grant.name || '-' }}
|
||||
</td>
|
||||
<td
|
||||
class="px-4 py-3 text-sm font-medium tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ grant.credits?.toLocaleString() || 0 }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
{{ grant.source || grant.category || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-n-slate-11">
|
||||
{{ grant.formattedEffectiveAt }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-n-slate-11">
|
||||
{{ grant.formattedExpiresAt }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end w-full">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('BILLING_SETTINGS.CREDIT_HISTORY.CLOSE')"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isPopular: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
return props.credits.toLocaleString();
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(props.amount);
|
||||
});
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'relative flex flex-col gap-2 p-4 transition-all border rounded-xl cursor-pointer';
|
||||
|
||||
if (props.isSelected) {
|
||||
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5`;
|
||||
}
|
||||
return `${baseClasses} border-n-weak hover:border-n-strong`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cardClasses" @click="$emit('select')">
|
||||
<div
|
||||
v-if="isPopular"
|
||||
class="absolute px-2 py-1 text-xs font-medium text-white rounded -top-3 left-3 bg-n-teal-9"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.MOST_POPULAR') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
|
||||
>
|
||||
<span class="text-white i-lucide-check size-4" />
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ formattedCredits }}
|
||||
</div>
|
||||
<div class="text-xs font-medium tracking-wider uppercase text-n-slate-10">
|
||||
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.CREDITS') }}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-lg font-semibold text-n-slate-12">{{
|
||||
formattedAmount
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
plan: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isCurrent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayPrice = computed(() => {
|
||||
const licenseFee = props.plan.components?.find(c => c.type === 'license_fee');
|
||||
const amount = licenseFee?.unit_amount || 0;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
});
|
||||
|
||||
const monthlyCredits = computed(() => {
|
||||
const serviceAction = props.plan.components?.find(
|
||||
c => c.type === 'service_action'
|
||||
);
|
||||
return serviceAction?.credit_amount || 0;
|
||||
});
|
||||
|
||||
const cardClasses = computed(() => {
|
||||
const baseClasses = 'relative flex flex-col gap-2 p-4 transition-all border rounded-xl';
|
||||
|
||||
if (props.isDisabled) {
|
||||
if (props.isSelected) {
|
||||
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 opacity-70 cursor-not-allowed`;
|
||||
}
|
||||
return `${baseClasses} border-n-weak opacity-50 cursor-not-allowed`;
|
||||
}
|
||||
|
||||
if (props.isSelected) {
|
||||
return `${baseClasses} border-n-teal-9 bg-n-teal-9/5 cursor-pointer`;
|
||||
}
|
||||
return `${baseClasses} border-n-weak hover:border-n-strong cursor-pointer`;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.isDisabled) {
|
||||
// Emit is handled in template
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cardClasses" @click="!isDisabled && $emit('select', plan)">
|
||||
<div
|
||||
v-if="isCurrent"
|
||||
class="absolute px-2 py-1 text-xs font-medium rounded -top-3 left-3 bg-n-solid-3 text-n-slate-11"
|
||||
>
|
||||
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.CURRENT_PLAN') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute flex items-center justify-center rounded-full -top-2 -right-2 size-6 bg-n-teal-9"
|
||||
>
|
||||
<span class="text-white i-lucide-check size-4" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-n-slate-12">
|
||||
{{ plan.display_name }}
|
||||
</h3>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ displayPrice }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.PER_USER_MONTH') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-n-slate-11">
|
||||
{{ monthlyCredits }}
|
||||
{{ t('BILLING_SETTINGS.CHANGE_PLAN_MODAL.AI_CREDITS_MONTH') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CreditPackageCard from './CreditPackageCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['purchase', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const selectedCredits = ref(null);
|
||||
|
||||
// The 3rd option (5000 credits) is marked as most popular based on Figma
|
||||
const popularCreditsAmount = 5000;
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const handlePurchase = () => {
|
||||
if (selectedOption.value) {
|
||||
emit('purchase', selectedOption.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
// Pre-select the most popular option
|
||||
const popularOption = props.options.find(
|
||||
o => o.credits === popularCreditsAmount
|
||||
);
|
||||
selectedCredits.value = popularOption?.credits || props.options[0]?.credits;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="t('BILLING_SETTINGS.PURCHASE_MODAL.TITLE')"
|
||||
:description="t('BILLING_SETTINGS.PURCHASE_MODAL.DESCRIPTION')"
|
||||
width="xl"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in options"
|
||||
:key="option.credits"
|
||||
:credits="option.credits"
|
||||
:amount="option.amount"
|
||||
:is-popular="option.credits === popularCreditsAmount"
|
||||
:is-selected="selectedCredits === option.credits"
|
||||
@select="handlePackageSelect(option.credits)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-4 border rounded-lg bg-n-alpha-1 border-n-weak">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('BILLING_SETTINGS.PURCHASE_MODAL.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
color="teal"
|
||||
solid
|
||||
:label="t('BILLING_SETTINGS.PURCHASE_MODAL.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
:is-loading="isLoading"
|
||||
@click="handlePurchase"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 999,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'cancel']);
|
||||
|
||||
const localValue = ref(props.modelValue);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newVal => {
|
||||
localValue.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const decrement = () => {
|
||||
if (localValue.value > props.min) {
|
||||
localValue.value -= 1;
|
||||
emit('update:modelValue', localValue.value);
|
||||
}
|
||||
};
|
||||
|
||||
const increment = () => {
|
||||
if (localValue.value < props.max) {
|
||||
localValue.value += 1;
|
||||
emit('update:modelValue', localValue.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', localValue.value);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
localValue.value = props.modelValue;
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center overflow-hidden border rounded-lg border-n-weak"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
|
||||
:disabled="localValue <= min || isLoading"
|
||||
@click="decrement"
|
||||
>
|
||||
<span class="i-lucide-minus size-4" />
|
||||
</button>
|
||||
<span
|
||||
class="flex items-center justify-center w-12 h-10 text-base font-medium tabular-nums text-n-slate-12"
|
||||
>
|
||||
{{ localValue }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-10 h-10 transition-colors text-n-slate-11 hover:bg-n-alpha-2 disabled:opacity-50"
|
||||
:disabled="localValue >= max || isLoading"
|
||||
@click="increment"
|
||||
>
|
||||
<span class="i-lucide-plus size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="link"
|
||||
color="slate"
|
||||
label="Cancel"
|
||||
:disabled="isLoading"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
solid
|
||||
slate
|
||||
sm
|
||||
label="Save"
|
||||
:is-loading="isLoading"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
actionText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
actionDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAction: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['action']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between py-4 border-b border-n-weak last:border-b-0"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
class="text-xs font-medium tracking-wider uppercase text-n-slate-10"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<slot name="value">
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
{{ value }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="action">
|
||||
<button
|
||||
v-if="actionText && showAction"
|
||||
type="button"
|
||||
class="text-sm font-medium transition-colors text-n-slate-11 hover:text-n-slate-12 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="actionDisabled"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,7 +18,17 @@ const state = {
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
isFetchingPricingPlans: false,
|
||||
isFetchingTopupOptions: false,
|
||||
isFetchingCreditGrants: false,
|
||||
isChangingPlan: false,
|
||||
isCancellingSubscription: false,
|
||||
isPurchasingCredits: false,
|
||||
isSubscribing: false,
|
||||
},
|
||||
pricingPlans: [],
|
||||
topupOptions: [],
|
||||
creditGrants: [],
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
@@ -28,6 +38,15 @@ export const getters = {
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getPricingPlans($state) {
|
||||
return $state.pricingPlans;
|
||||
},
|
||||
getTopupOptions($state) {
|
||||
return $state.topupOptions;
|
||||
},
|
||||
getCreditGrants($state) {
|
||||
return $state.creditGrants;
|
||||
},
|
||||
isRTL: ($state, _getters, rootState, rootGetters) => {
|
||||
const accountId = Number(rootState.route?.params?.accountId);
|
||||
const userLocale = rootGetters?.getUISettings?.locale;
|
||||
@@ -152,6 +171,120 @@ export const actions = {
|
||||
getCacheKeys: async () => {
|
||||
return AccountAPI.getCacheKeys();
|
||||
},
|
||||
|
||||
// V2 Billing Actions
|
||||
fetchPricingPlans: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingPricingPlans: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getPricingPlans();
|
||||
commit(types.default.SET_PRICING_PLANS, response.data.pricing_plans);
|
||||
} catch (error) {
|
||||
// silent error
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingPricingPlans: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchTopupOptions: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingTopupOptions: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getTopupOptions();
|
||||
commit(types.default.SET_TOPUP_OPTIONS, response.data.topup_options);
|
||||
} catch (error) {
|
||||
// silent error
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingTopupOptions: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
fetchCreditGrants: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingCreditGrants: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getCreditGrants();
|
||||
commit(types.default.SET_CREDIT_GRANTS, response.data.credit_grants);
|
||||
} catch (error) {
|
||||
// silent error
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingCreditGrants: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
changePricingPlan: async ({ commit }, { pricingPlanId, quantity }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.changePricingPlan(
|
||||
pricingPlanId,
|
||||
quantity
|
||||
);
|
||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isChangingPlan: false });
|
||||
}
|
||||
},
|
||||
|
||||
cancelAccountSubscription: async ({ commit }, { reason, feedback }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isCancellingSubscription: true,
|
||||
});
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.cancelSubscription(
|
||||
reason,
|
||||
feedback
|
||||
);
|
||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isCancellingSubscription: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
purchaseCredits: async ({ commit }, { credits }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.topupCredits(credits);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isPurchasingCredits: false });
|
||||
}
|
||||
},
|
||||
|
||||
subscribeToPlan: async ({ commit }, { pricingPlanId, quantity }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.subscribeToPlan(
|
||||
pricingPlanId,
|
||||
quantity
|
||||
);
|
||||
// Redirect to Stripe checkout
|
||||
if (response.data.redirect_url) {
|
||||
window.location = response.data.redirect_url;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
throw error;
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isSubscribing: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -164,6 +297,15 @@ export const mutations = {
|
||||
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
|
||||
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
|
||||
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
|
||||
[types.default.SET_PRICING_PLANS]($state, data) {
|
||||
$state.pricingPlans = data;
|
||||
},
|
||||
[types.default.SET_TOPUP_OPTIONS]($state, data) {
|
||||
$state.topupOptions = data;
|
||||
},
|
||||
[types.default.SET_CREDIT_GRANTS]($state, data) {
|
||||
$state.creditGrants = data;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -76,13 +76,16 @@ export default {
|
||||
EDIT_INBOXES: 'EDIT_INBOXES',
|
||||
DELETE_INBOXES: 'DELETE_INBOXES',
|
||||
|
||||
// Agent
|
||||
// Account
|
||||
SET_ACCOUNT_UI_FLAG: 'SET_ACCOUNT_UI_FLAG',
|
||||
SET_ACCOUNT_LIMITS: 'SET_ACCOUNT_LIMITS',
|
||||
SET_ACCOUNTS: 'SET_ACCOUNTS',
|
||||
ADD_ACCOUNT: 'ADD_ACCOUNT',
|
||||
EDIT_ACCOUNT: 'EDIT_ACCOUNT',
|
||||
DELETE_ACCOUNT: 'DELETE_AGENT',
|
||||
SET_PRICING_PLANS: 'SET_PRICING_PLANS',
|
||||
SET_TOPUP_OPTIONS: 'SET_TOPUP_OPTIONS',
|
||||
SET_CREDIT_GRANTS: 'SET_CREDIT_GRANTS',
|
||||
|
||||
// Agent
|
||||
SET_AGENT_FETCHING_STATUS: 'SET_AGENT_FETCHING_STATUS',
|
||||
|
||||
@@ -30,4 +30,49 @@ 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
|
||||
|
||||
# V2 Billing API actions
|
||||
def pricing_plans?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def topup_options?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def topup?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def subscribe?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,20 @@ if resource.custom_attributes.present?
|
||||
json.plan_name resource.custom_attributes['plan_name']
|
||||
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.subscription_ends_at resource.custom_attributes['subscription_ends_at']
|
||||
json.subscription_cancelled_at resource.custom_attributes['subscription_cancelled_at'] if resource.custom_attributes['subscription_cancelled_at'].present?
|
||||
json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present?
|
||||
json.stripe_plan_id resource.custom_attributes['stripe_plan_id'] if resource.custom_attributes['stripe_plan_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?
|
||||
|
||||
@@ -122,6 +122,12 @@ en:
|
||||
invalid_token: Invalid or expired MFA token
|
||||
invalid_credentials: Invalid credentials or verification code
|
||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
||||
enterprise:
|
||||
billing:
|
||||
topup_amount_invalid: Topup amount must be greater than 0
|
||||
stripe_customer_required: Customer ID required. Please create a Stripe customer first.
|
||||
lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id}
|
||||
v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.
|
||||
profile:
|
||||
mfa:
|
||||
enabled: MFA enabled successfully
|
||||
@@ -438,3 +444,8 @@ en:
|
||||
subject: 'Finish setting up %{custom_domain}'
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
enterprise:
|
||||
billing:
|
||||
topup_successful: Topup successful
|
||||
subscription_cancelled: Subscription cancelled
|
||||
pricing_plan_changed: Pricing plan changed
|
||||
|
||||
@@ -441,6 +441,20 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :v2 do
|
||||
resources :accounts, only: [] do
|
||||
resource :billing, only: [], controller: 'billing' do
|
||||
get :credit_grants
|
||||
get :pricing_plans
|
||||
get :topup_options
|
||||
post :topup
|
||||
post :subscribe
|
||||
post :cancel_subscription
|
||||
post :change_pricing_plan
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
|
||||
|
||||
@@ -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"
|
||||
@@ -1,5 +1,6 @@
|
||||
class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
include BillingHelper
|
||||
|
||||
before_action :fetch_account
|
||||
before_action :check_authorization
|
||||
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
class Enterprise::Api::V2::BillingController < Api::BaseController
|
||||
before_action :fetch_account
|
||||
before_action :check_billing_authorization
|
||||
before_action :validate_topup_amount, only: [:topup]
|
||||
|
||||
rescue_from StandardError, with: :render_error
|
||||
rescue_from NotImplementedError, with: :render_not_implemented
|
||||
|
||||
def credit_grants
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: @account)
|
||||
grants = service.fetch_credit_grants
|
||||
|
||||
render json: { credit_grants: grants }
|
||||
end
|
||||
|
||||
def pricing_plans
|
||||
plans = Enterprise::Billing::V2::PlanCatalog.plans
|
||||
render json: { pricing_plans: plans }
|
||||
end
|
||||
|
||||
def topup_options
|
||||
options = Enterprise::Billing::V2::TopupCatalog.options
|
||||
render json: { topup_options: options }
|
||||
end
|
||||
|
||||
def topup
|
||||
service = Enterprise::Billing::V2::TopupService.new(account: @account)
|
||||
result = service.create_topup(credits: params[:credits].to_i)
|
||||
|
||||
if result[:success]
|
||||
render json: { success: true, message: result[:message] }
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def subscribe
|
||||
service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account)
|
||||
redirect_url = service.create_subscription_checkout(
|
||||
pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: subscription_quantity
|
||||
)
|
||||
|
||||
render json: { redirect_url: redirect_url }
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account)
|
||||
result = service.cancel_subscription(
|
||||
reason: params[:reason],
|
||||
feedback: params[:feedback]
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
# Include account ID and updated attributes for frontend store update
|
||||
@account.reload
|
||||
render json: result.merge(
|
||||
id: @account.id,
|
||||
custom_attributes: @account.custom_attributes
|
||||
)
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def change_pricing_plan
|
||||
service = Enterprise::Billing::V2::ChangePlanService.new(account: @account)
|
||||
result = service.change_plan(
|
||||
new_pricing_plan_id: params[:pricing_plan_id],
|
||||
quantity: params[:quantity]&.to_i
|
||||
)
|
||||
|
||||
if result[:success]
|
||||
# Include account ID and updated attributes for frontend store update
|
||||
@account.reload
|
||||
render json: result.merge(
|
||||
id: @account.id,
|
||||
custom_attributes: @account.custom_attributes
|
||||
)
|
||||
else
|
||||
render json: { error: result[:message] }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:account_id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def subscription_quantity
|
||||
[params[:quantity].to_i, 1].max
|
||||
end
|
||||
|
||||
def validate_topup_amount
|
||||
return if params[:credits].to_i.positive?
|
||||
|
||||
render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def pundit_user
|
||||
{
|
||||
user: current_user,
|
||||
account: @account,
|
||||
account_user: @current_account_user
|
||||
}
|
||||
end
|
||||
|
||||
def render_error(exception)
|
||||
render json: { error: exception.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_not_implemented(exception)
|
||||
render json: { error: exception.message }, status: :not_implemented
|
||||
end
|
||||
|
||||
def check_billing_authorization
|
||||
authorize(@account, "#{action_name}?".to_sym)
|
||||
end
|
||||
end
|
||||
@@ -6,8 +6,17 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
|
||||
# Attempt to verify the signature. If successful, we'll handle the event
|
||||
begin
|
||||
event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil))
|
||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||
# Determine which webhook secret to use based on event type
|
||||
webhook_secret = determine_webhook_secret(payload)
|
||||
|
||||
event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
|
||||
# Check if this is a V2 billing event
|
||||
if v2_billing_event?(event.type)
|
||||
::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event)
|
||||
else
|
||||
::Enterprise::Billing::HandleStripeEventService.new.perform(event: event)
|
||||
end
|
||||
# If we fail to verify the signature, then something was wrong with the request
|
||||
rescue JSON::ParserError, Stripe::SignatureVerificationError
|
||||
# Invalid payload
|
||||
@@ -18,4 +27,27 @@ class Enterprise::Webhooks::StripeController < ActionController::API
|
||||
# We've successfully processed the event without blowing up
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_webhook_secret(payload)
|
||||
# Parse the payload to check event type without full verification
|
||||
parsed_payload = JSON.parse(payload)
|
||||
event_type = parsed_payload['type']
|
||||
|
||||
return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank?
|
||||
|
||||
if v2_billing_event?(event_type)
|
||||
ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil)
|
||||
else
|
||||
ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)
|
||||
end
|
||||
end
|
||||
|
||||
def v2_billing_event?(event_type)
|
||||
return false if event_type.blank?
|
||||
|
||||
Rails.logger.debug { "V2 billing event: #{event_type}" }
|
||||
event_type.start_with?('v2.')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,87 @@
|
||||
module Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startup plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
def update_plan_features(plan_name)
|
||||
if plan_name.blank? || plan_name == 'Hacker'
|
||||
disable_all_premium_features
|
||||
else
|
||||
enable_features_for_current_plan(plan_name)
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan or during plan changes)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan(plan_name)
|
||||
disable_all_premium_features
|
||||
enable_plan_specific_features(plan_name)
|
||||
end
|
||||
|
||||
def enable_plan_specific_features(plan_name)
|
||||
return if plan_name.blank?
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startups'
|
||||
# Startup plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startup features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage if account.respond_to?(:reset_response_usage)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def provision_new_plan(new_pricing_plan_id)
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(new_pricing_plan_id)
|
||||
return unless plan_definition
|
||||
|
||||
plan_name = plan_definition[:display_name]
|
||||
enable_plan_specific_features(plan_name) if plan_name.present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
module Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
extend ActiveSupport::Concern
|
||||
include Enterprise::Billing::Concerns::PlanDataHelper
|
||||
|
||||
private
|
||||
|
||||
def build_proration_line_items(context, proration_data)
|
||||
return build_seat_change_line_items(context, proration_data) if seat_only_change?(context)
|
||||
|
||||
build_plan_change_line_items(context, proration_data)
|
||||
end
|
||||
|
||||
def seat_only_change?(context)
|
||||
!context[:plan_changed] && context[:seats_changed]
|
||||
end
|
||||
|
||||
def build_seat_change_line_items(context, proration_data)
|
||||
[{
|
||||
amount: (proration_data[:net_amount] * 100).to_i,
|
||||
description: build_seat_change_description(context),
|
||||
metadata: build_seat_change_metadata(context, proration_data)
|
||||
}]
|
||||
end
|
||||
|
||||
def build_plan_change_line_items(context, proration_data)
|
||||
line_items = []
|
||||
old_plan_name = plan_display_name(context[:old_plan_id])
|
||||
new_plan_name = plan_display_name(context[:target_plan_id])
|
||||
|
||||
line_items << build_credit_line_item(context, proration_data, old_plan_name) if proration_data[:credit_amount].positive?
|
||||
line_items << build_charge_line_item(context, proration_data, new_plan_name) if proration_data[:charge_amount].positive?
|
||||
|
||||
line_items
|
||||
end
|
||||
|
||||
def build_credit_line_item(context, proration_data, old_plan_name)
|
||||
{
|
||||
amount: -(proration_data[:credit_amount] * 100).to_i,
|
||||
description: credit_description(old_plan_name, context[:old_quantity]),
|
||||
metadata: credit_metadata(old_plan_name, context[:old_quantity], proration_data[:days_remaining])
|
||||
}
|
||||
end
|
||||
|
||||
def build_charge_line_item(context, proration_data, new_plan_name)
|
||||
{
|
||||
amount: (proration_data[:charge_amount] * 100).to_i,
|
||||
description: charge_description(new_plan_name, context[:target_quantity]),
|
||||
metadata: charge_metadata(new_plan_name, context[:target_quantity], proration_data[:days_remaining])
|
||||
}
|
||||
end
|
||||
|
||||
def credit_description(plan_name, quantity)
|
||||
"Credit for unused time on #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
|
||||
end
|
||||
|
||||
def charge_description(plan_name, quantity)
|
||||
"Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})"
|
||||
end
|
||||
|
||||
def build_seat_change_description(context)
|
||||
plan_name = plan_display_name(context[:target_plan_id])
|
||||
change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease'
|
||||
quantity_diff = (context[:target_quantity] - context[:old_quantity]).abs
|
||||
|
||||
"Seat #{change_type} for #{plan_name}: #{context[:old_quantity]} → #{context[:target_quantity]} " \
|
||||
"seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})"
|
||||
end
|
||||
|
||||
def base_metadata(type, days_remaining, **additional_fields)
|
||||
{
|
||||
type: type,
|
||||
days_remaining: days_remaining,
|
||||
billing_version: 'v2'
|
||||
}.merge(additional_fields)
|
||||
end
|
||||
|
||||
def credit_metadata(plan_name, quantity, days_remaining)
|
||||
base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity)
|
||||
end
|
||||
|
||||
def charge_metadata(plan_name, quantity, days_remaining)
|
||||
base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity)
|
||||
end
|
||||
|
||||
def build_seat_change_metadata(context, proration_data)
|
||||
base_metadata('seat_change', proration_data[:days_remaining],
|
||||
plan_name: plan_display_name(context[:target_plan_id]),
|
||||
old_quantity: context[:old_quantity],
|
||||
new_quantity: context[:target_quantity],
|
||||
quantity_change: context[:target_quantity] - context[:old_quantity])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
module Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Stripe client instance with API key
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil))
|
||||
end
|
||||
|
||||
# Pricing Plan Subscriptions
|
||||
def retrieve_pricing_plan_subscription(subscription_id)
|
||||
stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id)
|
||||
end
|
||||
|
||||
# Pricing Plans
|
||||
def retrieve_pricing_plan(pricing_plan_id)
|
||||
stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id)
|
||||
end
|
||||
|
||||
def retrieve_billing_cadence(cadence_id)
|
||||
stripe_client.v2.billing.cadences.retrieve(cadence_id)
|
||||
end
|
||||
|
||||
def create_billing_intent(params)
|
||||
response = Faraday.post('https://api.stripe.com/v2/billing/intents') do |req|
|
||||
req.headers['Authorization'] = "Bearer #{ENV.fetch('STRIPE_SECRET_KEY', nil)}"
|
||||
req.headers['Stripe-Version'] = default_stripe_version
|
||||
req.headers['Content-Type'] = 'application/json'
|
||||
req.body = params.to_json
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def reserve_billing_intent(billing_intent_id)
|
||||
stripe_client.v2.billing.intents.reserve(billing_intent_id)
|
||||
end
|
||||
|
||||
def commit_billing_intent(billing_intent_id)
|
||||
stripe_client.v2.billing.intents.commit(billing_intent_id)
|
||||
end
|
||||
|
||||
# Checkout Sessions (V1 API but used with V2 plans)
|
||||
def create_checkout_session(params)
|
||||
Stripe::Checkout::Session.create(params, { stripe_version: checkout_stripe_version })
|
||||
end
|
||||
|
||||
# Credit Grants (V1 API but used with V2)
|
||||
def retrieve_credit_grant(grant_id)
|
||||
Stripe::Billing::CreditGrant.retrieve(grant_id)
|
||||
end
|
||||
|
||||
def default_stripe_version
|
||||
'2025-10-29.preview'
|
||||
end
|
||||
|
||||
def checkout_stripe_version
|
||||
'2025-10-29.preview;checkout_product_catalog_preview=v1'
|
||||
end
|
||||
|
||||
def extract_attribute(object, key)
|
||||
return object.public_send(key) if object.respond_to?(key)
|
||||
|
||||
object[key.to_s]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
module Enterprise::Billing::Concerns::SubscriptionDataManager
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def fetch_subscription_metadata
|
||||
subscription_id = fetch_subscription_id
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
cadence_id = extract_cadence_id(subscription)
|
||||
cadence = retrieve_billing_cadence(cadence_id)
|
||||
next_billing_date = extract_attribute(cadence, :next_billing_date)
|
||||
|
||||
store_next_billing_date(next_billing_date)
|
||||
|
||||
{
|
||||
subscription_id: subscription_id,
|
||||
subscription: subscription,
|
||||
cadence_id: cadence_id,
|
||||
cadence: cadence,
|
||||
next_billing_date: next_billing_date
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_subscription_id
|
||||
subscription_id = custom_attribute('stripe_subscription_id')
|
||||
raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank?
|
||||
|
||||
subscription_id
|
||||
end
|
||||
|
||||
def extract_cadence_id(subscription)
|
||||
cadence_id = extract_attribute(subscription, :billing_cadence)
|
||||
raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank?
|
||||
|
||||
cadence_id
|
||||
end
|
||||
|
||||
def store_next_billing_date(next_billing_date)
|
||||
update_custom_attributes({ 'next_billing_date' => next_billing_date })
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Enterprise::Billing::CreateStripeCustomerService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
DEFAULT_QUANTITY = 2
|
||||
@@ -6,22 +8,11 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
def perform
|
||||
return if existing_subscription?
|
||||
|
||||
raise_config_error unless v2_configs_present?
|
||||
|
||||
customer_id = prepare_customer_id
|
||||
subscription = Stripe::Subscription.create(
|
||||
{
|
||||
customer: customer_id,
|
||||
items: [{ price: price_id, quantity: default_quantity }]
|
||||
}
|
||||
)
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_price_id: subscription['plan']['id'],
|
||||
stripe_product_id: subscription['plan']['product'],
|
||||
plan_name: default_plan['name'],
|
||||
subscribed_quantity: subscription['quantity']
|
||||
}
|
||||
)
|
||||
update_account_for_v2_billing(customer_id)
|
||||
enable_plan_specific_features('Hacker')
|
||||
end
|
||||
|
||||
private
|
||||
@@ -35,22 +26,16 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
customer_id
|
||||
end
|
||||
|
||||
def default_quantity
|
||||
default_plan['default_quantity'] || DEFAULT_QUANTITY
|
||||
end
|
||||
|
||||
def billing_email
|
||||
account.administrators.first.email
|
||||
end
|
||||
|
||||
def default_plan
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
@default_plan ||= installation_config.value.first
|
||||
def v2_configs_present?
|
||||
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present?
|
||||
end
|
||||
|
||||
def price_id
|
||||
price_ids = default_plan['price_ids']
|
||||
price_ids.first
|
||||
def raise_config_error
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required')
|
||||
end
|
||||
|
||||
def existing_subscription?
|
||||
@@ -66,4 +51,23 @@ class Enterprise::Billing::CreateStripeCustomerService
|
||||
)
|
||||
subscriptions.data.present?
|
||||
end
|
||||
|
||||
def update_account_for_v2_billing(customer_id)
|
||||
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
|
||||
|
||||
attributes = {
|
||||
stripe_customer_id: customer_id,
|
||||
stripe_billing_version: 2
|
||||
}
|
||||
|
||||
if hacker_plan_config&.value.present?
|
||||
attributes.merge!(
|
||||
stripe_pricing_plan_id: hacker_plan_config.value,
|
||||
plan_name: 'Hacker',
|
||||
subscribed_quantity: DEFAULT_QUANTITY
|
||||
)
|
||||
end
|
||||
|
||||
account.update!(custom_attributes: attributes)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,30 +1,9 @@
|
||||
class Enterprise::Billing::HandleStripeEventService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
|
||||
# Basic features available starting with the Startups plan
|
||||
STARTUP_PLAN_FEATURES = %w[
|
||||
inbound_emails
|
||||
help_center
|
||||
campaigns
|
||||
team_management
|
||||
channel_twitter
|
||||
channel_facebook
|
||||
channel_email
|
||||
channel_instagram
|
||||
captain_integration
|
||||
advanced_search_indexing
|
||||
advanced_search
|
||||
].freeze
|
||||
|
||||
# Additional features available starting with the Business plan
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# Additional features available only in the Enterprise plan
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
|
||||
def perform(event:)
|
||||
@event = event
|
||||
|
||||
@@ -33,6 +12,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
process_subscription_updated
|
||||
when 'customer.subscription.deleted'
|
||||
process_subscription_deleted
|
||||
when 'billing.credit_grant.created'
|
||||
process_credit_grant_created
|
||||
else
|
||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||
end
|
||||
@@ -47,7 +28,8 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
return if plan.blank? || account.blank?
|
||||
|
||||
update_account_attributes(subscription, plan)
|
||||
update_plan_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
update_plan_features(plan_name)
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
@@ -73,65 +55,22 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
|
||||
def update_plan_features
|
||||
if default_plan?
|
||||
disable_all_premium_features
|
||||
else
|
||||
enable_features_for_current_plan
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
end
|
||||
|
||||
def disable_all_premium_features
|
||||
# Disable all features (for default Hacker plan)
|
||||
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
|
||||
def enable_features_for_current_plan
|
||||
# First disable all premium features to handle downgrades
|
||||
disable_all_premium_features
|
||||
|
||||
# Then enable features based on the current plan
|
||||
enable_plan_specific_features
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage
|
||||
end
|
||||
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startups'
|
||||
# Startups plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startups features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
def subscription
|
||||
@subscription ||= @event.data.object
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
@account ||= begin
|
||||
customer_id = if @event.type.start_with?('billing.credit_grant')
|
||||
# Credit grant events have customer directly on the object
|
||||
@event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer']
|
||||
else
|
||||
# Subscription events have customer on subscription
|
||||
subscription.customer
|
||||
end
|
||||
|
||||
Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first
|
||||
end
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
@@ -139,18 +78,60 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
default_plan = cloud_plans.first || {}
|
||||
account.custom_attributes['plan_name'] == default_plan['name']
|
||||
def process_credit_grant_created
|
||||
grant_id = extract_credit_grant_id(@event.data.object)
|
||||
return if grant_id.blank?
|
||||
|
||||
# Retrieve the full credit grant object from Stripe API
|
||||
grant = retrieve_credit_grant(grant_id)
|
||||
return if grant.blank?
|
||||
|
||||
amount = extract_credit_amount(grant)
|
||||
return if amount.zero?
|
||||
|
||||
grant_type = extract_grant_type(grant)
|
||||
service = Enterprise::Billing::V2::CreditManagementService.new(account: account)
|
||||
if grant_type == 'monetary'
|
||||
service.add_response_topup_credits(amount)
|
||||
else
|
||||
service.sync_monthly_response_credits(amount)
|
||||
end
|
||||
end
|
||||
|
||||
def enable_account_manually_managed_features
|
||||
# Get manually managed features from internal attributes using the service
|
||||
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||
features = service.manually_managed_features
|
||||
def extract_credit_grant_id(grant_object)
|
||||
grant_object.respond_to?(:id) ? grant_object.id : grant_object['id']
|
||||
end
|
||||
|
||||
# Enable each feature
|
||||
account.enable_features(*features) if features.present?
|
||||
def extract_grant_type(grant)
|
||||
amount_object = extract_attribute(grant, :amount)
|
||||
return 'monetary' if amount_object.blank?
|
||||
|
||||
type = extract_attribute(amount_object, :type)
|
||||
return 'monetary' if type.blank?
|
||||
|
||||
type
|
||||
end
|
||||
|
||||
def extract_credit_amount(grant)
|
||||
# First, try to get credits from metadata
|
||||
metadata = extract_attribute(grant, :metadata)
|
||||
if metadata
|
||||
credits = extract_attribute(metadata, :credits)
|
||||
return credits.to_i if credits.present? && credits.to_i.positive?
|
||||
end
|
||||
amount = extract_attribute(grant, :amount)
|
||||
return 0 if amount.blank?
|
||||
|
||||
custom_pricing_unit = extract_attribute(amount, :custom_pricing_unit)
|
||||
return 0 if custom_pricing_unit.blank?
|
||||
|
||||
value = extract_attribute(custom_pricing_unit, :value)
|
||||
return 0 if value.blank?
|
||||
|
||||
value.to_i
|
||||
end
|
||||
|
||||
def extract_attribute(object, attribute)
|
||||
object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
class Enterprise::Billing::V2::BaseService
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(
|
||||
api_key: ENV.fetch('STRIPE_SECRET_KEY', nil)
|
||||
)
|
||||
end
|
||||
|
||||
def response_monthly_credits
|
||||
account.limits&.[]('captain_responses_monthly').to_i
|
||||
end
|
||||
|
||||
def response_topup_credits
|
||||
account.limits&.[]('captain_responses_topup').to_i
|
||||
end
|
||||
|
||||
def response_usage
|
||||
account.custom_attributes&.[]('captain_responses_usage').to_i
|
||||
end
|
||||
|
||||
# Update response credits (monthly/topup with auto-calculation of total)
|
||||
def update_response_credits(monthly: nil, topup: nil)
|
||||
# Calculate and update total in limits hash ONLY
|
||||
return if monthly.nil? && topup.nil?
|
||||
|
||||
new_monthly = monthly || response_monthly_credits
|
||||
new_topup = topup || response_topup_credits
|
||||
total_credits = new_monthly + new_topup
|
||||
limits = {
|
||||
'captain_responses_monthly' => new_monthly,
|
||||
'captain_responses_topup' => new_topup,
|
||||
'captain_responses' => total_credits
|
||||
}
|
||||
update_limits(limits)
|
||||
end
|
||||
|
||||
def update_limits(updates)
|
||||
return if updates.blank?
|
||||
|
||||
account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s)))
|
||||
end
|
||||
|
||||
def update_custom_attributes(updates)
|
||||
return if updates.blank?
|
||||
|
||||
account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s)))
|
||||
end
|
||||
|
||||
def custom_attribute(key)
|
||||
account.custom_attributes&.[](key.to_s)
|
||||
end
|
||||
|
||||
def with_locked_account(&)
|
||||
account.with_lock(&)
|
||||
end
|
||||
|
||||
# Convenient accessors for common attributes
|
||||
def stripe_customer_id
|
||||
custom_attribute('stripe_customer_id')
|
||||
end
|
||||
|
||||
def stripe_subscription_id
|
||||
custom_attribute('stripe_subscription_id')
|
||||
end
|
||||
|
||||
def pricing_plan_id
|
||||
custom_attribute('stripe_pricing_plan_id')
|
||||
end
|
||||
|
||||
def subscribed_quantity
|
||||
custom_attribute('subscribed_quantity').to_i
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
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
|
||||
#
|
||||
# @param reason [String] Optional cancellation reason
|
||||
# @param feedback [String] Optional additional feedback
|
||||
# @return [Hash] { success:, cancel_at_period_end:, period_end:, message: }
|
||||
#
|
||||
def cancel_subscription(reason: nil, feedback: nil)
|
||||
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], reason: reason, feedback: feedback)
|
||||
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, reason: nil, feedback: nil)
|
||||
# Mark subscription as cancelling (will be cancelled at period end)
|
||||
# Store next_billing_date so the UI can show when the subscription ends
|
||||
attributes = {
|
||||
'subscription_status' => 'cancel_at_period_end',
|
||||
'subscription_cancelled_at' => Time.current.iso8601,
|
||||
'subscription_ends_at' => next_billing_date
|
||||
}
|
||||
attributes['cancellation_reason'] = reason if reason.present?
|
||||
attributes['cancellation_feedback'] = feedback if feedback.present?
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
def build_success_response
|
||||
{
|
||||
success: true,
|
||||
cancel_at_period_end: true,
|
||||
message: 'Subscription cancellation initiated. It will be deactivated at the end of the current billing period.'
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
include Enterprise::Billing::Concerns::ProrationLineItemBuilder
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
include Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
include Enterprise::Billing::Concerns::BillingIntentWorkflow
|
||||
include Enterprise::Billing::Concerns::SubscriptionDataManager
|
||||
include Enterprise::Billing::Concerns::PlanDataHelper
|
||||
|
||||
def change_plan(new_pricing_plan_id: nil, quantity: nil)
|
||||
validation_error = validate_parameters(new_pricing_plan_id, quantity)
|
||||
return validation_error if validation_error
|
||||
|
||||
with_locked_account do
|
||||
perform_subscription_change(new_pricing_plan_id, quantity)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters(new_pricing_plan_id, quantity)
|
||||
return { success: false, message: 'Must specify either new_pricing_plan_id or quantity' } if new_pricing_plan_id.nil? && quantity.nil?
|
||||
return { success: false, message: 'Invalid quantity' } if !quantity.nil? && !quantity.positive?
|
||||
|
||||
# Validate customer has a default payment method using common service
|
||||
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
|
||||
payment_method_validation = payment_service.validate_payment_method
|
||||
return payment_method_validation unless payment_method_validation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def perform_subscription_change(new_pricing_plan_id, quantity)
|
||||
change_context = build_change_context(new_pricing_plan_id, quantity)
|
||||
return no_change_response(change_context) unless change_required?(change_context)
|
||||
|
||||
execute_change(change_context)
|
||||
end
|
||||
|
||||
def build_change_context(new_pricing_plan_id, quantity)
|
||||
old_plan_id = pricing_plan_id
|
||||
old_quantity = subscribed_quantity
|
||||
target_plan_id = new_pricing_plan_id || old_plan_id
|
||||
target_quantity = quantity || old_quantity
|
||||
|
||||
{
|
||||
old_plan_id: old_plan_id,
|
||||
old_quantity: old_quantity,
|
||||
target_plan_id: target_plan_id,
|
||||
target_quantity: target_quantity,
|
||||
plan_changed: old_plan_id != target_plan_id,
|
||||
seats_changed: old_quantity != target_quantity
|
||||
}
|
||||
end
|
||||
|
||||
def change_required?(context)
|
||||
context[:plan_changed] || context[:seats_changed]
|
||||
end
|
||||
|
||||
def no_change_response(context)
|
||||
plan_name = plan_display_name(context[:target_plan_id])
|
||||
{ success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" }
|
||||
end
|
||||
|
||||
def execute_change(context)
|
||||
# Create and execute billing intent to update the Stripe subscription
|
||||
intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity])
|
||||
execute_billing_intent(intent_params)
|
||||
|
||||
next_billing_date = custom_attribute('next_billing_date')
|
||||
proration_data = calculate_proration(
|
||||
old_plan_id: context[:old_plan_id],
|
||||
new_plan_id: context[:target_plan_id],
|
||||
old_quantity: context[:old_quantity],
|
||||
new_quantity: context[:target_quantity],
|
||||
next_billing_date: next_billing_date
|
||||
)
|
||||
line_items = build_proration_line_items(context, proration_data)
|
||||
create_and_charge_invoice(line_items)
|
||||
|
||||
update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date)
|
||||
provision_new_plan(context[:target_plan_id]) if context[:plan_changed]
|
||||
|
||||
{ success: true, message: 'Plan change successful' }
|
||||
end
|
||||
|
||||
def build_change_plan_params(new_pricing_plan_id, quantity)
|
||||
metadata = fetch_subscription_metadata
|
||||
plan_version = fetch_plan_version(new_pricing_plan_id)
|
||||
lookup_key = fetch_plan_lookup_key(new_pricing_plan_id)
|
||||
|
||||
{
|
||||
cadence: metadata[:cadence_id],
|
||||
currency: 'usd',
|
||||
actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)]
|
||||
}
|
||||
end
|
||||
|
||||
def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity)
|
||||
{
|
||||
type: 'modify',
|
||||
modify: {
|
||||
type: 'pricing_plan_subscription_details',
|
||||
pricing_plan_subscription_details: {
|
||||
pricing_plan_subscription: subscription_id,
|
||||
new_pricing_plan: plan_id,
|
||||
new_pricing_plan_version: plan_version,
|
||||
component_configurations: [{ lookup_key: lookup_key, quantity: quantity }]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:)
|
||||
old_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(old_plan_id)&.dig(:base_fee) || 0.0
|
||||
new_plan_price = Enterprise::Billing::V2::PlanCatalog.definition_for(new_plan_id)&.dig(:base_fee) || 0.0
|
||||
|
||||
Enterprise::Billing::V2::ProrationCalculator.calculate(
|
||||
old_plan_price: old_plan_price,
|
||||
new_plan_price: new_plan_price,
|
||||
old_quantity: old_quantity,
|
||||
new_quantity: new_quantity,
|
||||
next_billing_date: next_billing_date
|
||||
)
|
||||
end
|
||||
|
||||
def create_and_charge_invoice(line_items)
|
||||
# Return success with no invoice if no line items (negligible proration)
|
||||
return { success: true, amount: 0.0, message: 'No charges due to negligible proration' } if line_items.empty?
|
||||
|
||||
# Use common invoice payment service
|
||||
payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account)
|
||||
payment_service.create_and_pay_invoice(
|
||||
line_items: line_items,
|
||||
description: 'Proration charges for plan/seat changes',
|
||||
metadata: {
|
||||
account_id: account.id.to_s,
|
||||
type: 'proration'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def update_account_plan(plan_id, quantity, next_billing_date)
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)
|
||||
plan_name = plan_definition&.dig(:display_name)
|
||||
|
||||
update_custom_attributes({
|
||||
'pricing_plan_id' => plan_id,
|
||||
'subscribed_quantity' => quantity,
|
||||
'plan_name' => plan_name,
|
||||
'next_billing_date' => next_billing_date
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def create_subscription_checkout(pricing_plan_id:, quantity: 1)
|
||||
@pricing_plan_id = pricing_plan_id
|
||||
@quantity = quantity.to_i.positive? ? quantity.to_i : 1
|
||||
|
||||
base_url = ENV.fetch('FRONTEND_URL')
|
||||
@success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}"
|
||||
@cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing"
|
||||
|
||||
validate_params
|
||||
store_pending_subscription_quantity
|
||||
session = create_checkout_session(checkout_session_params)
|
||||
session.url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_params
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank?
|
||||
end
|
||||
|
||||
def store_pending_subscription_quantity
|
||||
# Store quantity in custom_attributes for webhook to use
|
||||
# This is more reliable than extracting from subscription component_values
|
||||
update_custom_attributes({
|
||||
'pending_subscription_quantity' => @quantity,
|
||||
'pending_subscription_pricing_plan' => @pricing_plan_id
|
||||
})
|
||||
end
|
||||
|
||||
def checkout_session_params
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
checkout_items: build_checkout_items,
|
||||
automatic_tax: {
|
||||
enabled: true
|
||||
},
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
shipping: 'auto'
|
||||
},
|
||||
success_url: @success_url,
|
||||
cancel_url: @cancel_url,
|
||||
metadata: session_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def build_checkout_items
|
||||
lookup_key = extract_license_lookup_key
|
||||
raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key
|
||||
|
||||
[
|
||||
{
|
||||
type: 'pricing_plan_subscription_item',
|
||||
pricing_plan_subscription_item: {
|
||||
pricing_plan: @pricing_plan_id,
|
||||
component_configurations: {
|
||||
lookup_key => {
|
||||
type: 'license_fee_component',
|
||||
license_fee_component: {
|
||||
quantity: @quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def extract_license_lookup_key
|
||||
Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id)
|
||||
end
|
||||
|
||||
def session_metadata
|
||||
{
|
||||
account_id: account.id,
|
||||
pricing_plan_id: @pricing_plan_id,
|
||||
quantity: @quantity,
|
||||
billing_version: 'v2'
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2::BaseService
|
||||
# Sync monthly response credits (resets on billing cycle with topup preservation)
|
||||
def sync_monthly_response_credits(amount)
|
||||
with_locked_account do
|
||||
# Preserve topup credits but cap at remaining balance
|
||||
preserved_topup = preserve_topup_on_reset(
|
||||
current_topup: response_topup_credits,
|
||||
new_monthly: amount,
|
||||
current_usage: response_usage
|
||||
)
|
||||
update_response_credits(monthly: amount, topup: preserved_topup)
|
||||
end
|
||||
end
|
||||
|
||||
# Add topup credits for responses
|
||||
def add_response_topup_credits(amount)
|
||||
with_locked_account do
|
||||
new_topup = response_topup_credits + amount
|
||||
update_response_credits(topup: new_topup)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_credit_grants
|
||||
return [] if stripe_customer_id.blank?
|
||||
|
||||
response = Stripe::Billing::CreditGrant.list(
|
||||
{ customer: stripe_customer_id, limit: 100 }
|
||||
)
|
||||
|
||||
grants = response.data.map do |grant|
|
||||
transform_credit_grant(grant)
|
||||
end
|
||||
grants.reject { |grant| grant[:credits].zero? }
|
||||
rescue Stripe::StripeError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Preserve topup credits on monthly reset, capped at remaining balance
|
||||
# Formula: min(current_topup, max(0, (new_monthly + current_topup) - current_usage))
|
||||
def preserve_topup_on_reset(current_topup:, new_monthly:, current_usage:)
|
||||
# Calculate remaining balance after usage
|
||||
total_after_sync = new_monthly + current_topup
|
||||
remaining_balance = [total_after_sync - current_usage, 0].max
|
||||
|
||||
# Cap topup at remaining balance to avoid over-crediting
|
||||
[current_topup, remaining_balance].min
|
||||
end
|
||||
|
||||
def transform_credit_grant(grant)
|
||||
category = grant_attribute(grant, :category)
|
||||
metadata = grant_attribute(grant, :metadata) || {}
|
||||
|
||||
{
|
||||
id: grant_attribute(grant, :id),
|
||||
name: grant_attribute(grant, :name),
|
||||
credits: calculate_grant_credits(category, metadata),
|
||||
category: category,
|
||||
source: metadata['source'] || category,
|
||||
effective_at: parse_timestamp(grant_attribute(grant, :effective_at)),
|
||||
expires_at: parse_timestamp(grant_attribute(grant, :expires_at)),
|
||||
voided_at: parse_timestamp(grant_attribute(grant, :voided_at)),
|
||||
created_at: parse_timestamp(grant_attribute(grant, :created))
|
||||
}
|
||||
end
|
||||
|
||||
def grant_attribute(grant, key)
|
||||
grant[key] || grant.public_send(key)
|
||||
end
|
||||
|
||||
def calculate_grant_credits(category, metadata)
|
||||
return metadata['credits'].to_i if category == 'paid' && metadata['credits']
|
||||
|
||||
0
|
||||
end
|
||||
|
||||
def parse_timestamp(timestamp)
|
||||
return nil unless timestamp
|
||||
|
||||
Time.zone.at(timestamp)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,138 @@
|
||||
class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2::BaseService
|
||||
# Common service for creating invoices with line items and charging immediately
|
||||
# Used by both TopupService and ChangePlanService
|
||||
#
|
||||
# @param line_items [Array<Hash>] Array of line items: [{ amount:, description:, metadata: }]
|
||||
# @param description [String] Description for the invoice
|
||||
# @param currency [String] Currency code (default: 'usd')
|
||||
# @param metadata [Hash] Metadata for the invoice
|
||||
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
|
||||
|
||||
# Validate that customer has a default payment method
|
||||
# If no default is set but payment methods exist, automatically set the first one as default
|
||||
# @return [Hash, nil] Returns error hash if validation fails, nil if success
|
||||
def validate_payment_method
|
||||
return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank?
|
||||
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
|
||||
if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil?
|
||||
# No default payment method found - try to set one automatically
|
||||
ensure_default_payment_method_result = ensure_default_payment_method(customer)
|
||||
return ensure_default_payment_method_result unless ensure_default_payment_method_result.nil?
|
||||
end
|
||||
|
||||
nil
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Failed to check payment method: #{e.message}")
|
||||
{ success: false, message: "Error validating payment method: #{e.message}" }
|
||||
end
|
||||
|
||||
# Ensure a default payment method is set for the customer
|
||||
# If payment methods exist but none is default, set the first one as default
|
||||
# @param customer [Stripe::Customer] The Stripe customer object
|
||||
# @return [Hash, nil] Returns error hash if no payment methods exist, nil if default is set successfully
|
||||
def ensure_default_payment_method(customer)
|
||||
payment_methods = fetch_customer_payment_methods(customer.id)
|
||||
return no_payment_methods_error if payment_methods.data.empty?
|
||||
|
||||
set_first_payment_method_as_default(customer.id, payment_methods.data.first)
|
||||
nil
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Failed to set default payment method: #{e.message}")
|
||||
{ success: false, message: "Error setting default payment method: #{e.message}" }
|
||||
end
|
||||
|
||||
def fetch_customer_payment_methods(customer_id)
|
||||
Stripe::PaymentMethod.list(customer: customer_id, limit: 100)
|
||||
end
|
||||
|
||||
def no_payment_methods_error
|
||||
{
|
||||
success: false,
|
||||
message: 'No payment methods found. Please add a payment method before making a purchase.'
|
||||
}
|
||||
end
|
||||
|
||||
def set_first_payment_method_as_default(customer_id, payment_method)
|
||||
Stripe::Customer.update(
|
||||
customer_id,
|
||||
invoice_settings: { default_payment_method: payment_method.id }
|
||||
)
|
||||
Rails.logger.info("Automatically set payment method #{payment_method.id} as default for customer #{customer_id}")
|
||||
end
|
||||
|
||||
# Create invoice with line items and charge immediately
|
||||
#
|
||||
# @param line_items [Array<Hash>] Line items: [{ amount: (cents), description:, metadata: }]
|
||||
# @param description [String] Invoice description
|
||||
# @param currency [String] Currency (default: 'usd')
|
||||
# @param metadata [Hash] Invoice metadata
|
||||
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
|
||||
def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {})
|
||||
invoice = create_invoice(stripe_customer_id, currency, description, metadata)
|
||||
add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency)
|
||||
finalize_and_pay_invoice(invoice.id)
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Error creating invoice: #{e.message}")
|
||||
{ success: false, message: "Error creating invoice: #{e.message}" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_invoice(customer_id, currency, description, metadata)
|
||||
Stripe::Invoice.create({
|
||||
customer: customer_id,
|
||||
currency: currency,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: false,
|
||||
description: description,
|
||||
metadata: metadata.stringify_keys
|
||||
})
|
||||
end
|
||||
|
||||
def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency)
|
||||
line_items.each do |item|
|
||||
Stripe::InvoiceItem.create({
|
||||
customer: customer_id,
|
||||
amount: item[:amount],
|
||||
currency: currency,
|
||||
invoice: invoice_id,
|
||||
description: item[:description],
|
||||
metadata: (item[:metadata] || {}).stringify_keys
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Finalize invoice and pay it immediately
|
||||
# @param invoice_id [String] Stripe invoice ID
|
||||
# @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: }
|
||||
def finalize_and_pay_invoice(invoice_id)
|
||||
# Finalize the invoice
|
||||
finalized_invoice = Stripe::Invoice.finalize_invoice(
|
||||
invoice_id,
|
||||
{ auto_advance: false }
|
||||
)
|
||||
|
||||
# Pay the invoice immediately if not already paid
|
||||
if finalized_invoice.status == 'paid'
|
||||
build_invoice_response(finalized_invoice)
|
||||
else
|
||||
paid_invoice = Stripe::Invoice.pay(invoice_id, {})
|
||||
build_invoice_response(paid_invoice)
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error("Error finalizing/paying invoice: #{e.message}")
|
||||
{ success: false, message: "Error processing payment: #{e.message}" }
|
||||
end
|
||||
|
||||
def build_invoice_response(invoice)
|
||||
{
|
||||
success: true,
|
||||
invoice_id: invoice.id,
|
||||
invoice_url: invoice.hosted_invoice_url,
|
||||
amount: invoice.total / 100.0,
|
||||
status: invoice.status
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
module Enterprise::Billing::V2::PlanCatalog
|
||||
DEFAULT_CURRENCY = 'usd'.freeze
|
||||
CREDIT_UNIT = 'Credits'.freeze
|
||||
|
||||
PLAN_DEFINITIONS = [
|
||||
{
|
||||
key: :free,
|
||||
display_name: 'Hacker',
|
||||
base_fee: 0.0,
|
||||
monthly_credits: 0,
|
||||
config_key: 'STRIPE_HACKER_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :startup,
|
||||
display_name: 'Startups',
|
||||
base_fee: 19.0,
|
||||
monthly_credits: 300,
|
||||
config_key: 'STRIPE_STARTUP_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :business,
|
||||
display_name: 'Business',
|
||||
base_fee: 39.0,
|
||||
monthly_credits: 500,
|
||||
config_key: 'STRIPE_BUSINESS_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_business_license_fee_v2'
|
||||
},
|
||||
{
|
||||
key: :enterprise,
|
||||
display_name: 'Enterprise',
|
||||
base_fee: 99.0,
|
||||
monthly_credits: 800,
|
||||
config_key: 'STRIPE_ENTERPRISE_PLAN_ID',
|
||||
licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2'
|
||||
}
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def plans
|
||||
PLAN_DEFINITIONS.map do |definition|
|
||||
plan_id = plan_id_for(definition)
|
||||
build_plan(definition, plan_id)
|
||||
end
|
||||
end
|
||||
|
||||
def definition_for(plan_id)
|
||||
PLAN_DEFINITIONS.each do |definition|
|
||||
return definition if plan_id_for(definition) == plan_id
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def plan_id_for(definition)
|
||||
InstallationConfig.find_by(name: definition[:config_key])&.value
|
||||
end
|
||||
|
||||
def lookup_key_for_plan(plan_id)
|
||||
# Returns the licensed_item_lookup_key for checkout sessions
|
||||
definition = definition_for(plan_id)
|
||||
definition&.dig(:licensed_item_lookup_key)
|
||||
end
|
||||
|
||||
def build_plan(definition, plan_id)
|
||||
{
|
||||
id: plan_id,
|
||||
display_name: definition[:display_name],
|
||||
currency: DEFAULT_CURRENCY,
|
||||
tax_behavior: 'exclusive',
|
||||
components: build_components(definition)
|
||||
}
|
||||
end
|
||||
|
||||
def build_components(definition)
|
||||
components = [service_action_component(definition), rate_card_component(definition)]
|
||||
components << license_fee_component(definition) if definition[:base_fee]&.positive?
|
||||
components
|
||||
end
|
||||
|
||||
def service_action_component(definition)
|
||||
{
|
||||
type: 'service_action',
|
||||
name: 'Monthly Credits',
|
||||
credit_amount: definition[:monthly_credits],
|
||||
credit_unit: CREDIT_UNIT
|
||||
}
|
||||
end
|
||||
|
||||
def rate_card_component(_definition)
|
||||
{
|
||||
type: 'rate_card',
|
||||
name: 'Overage Rate',
|
||||
rate_unit: CREDIT_UNIT,
|
||||
meter_id: nil
|
||||
}
|
||||
end
|
||||
|
||||
def license_fee_component(definition)
|
||||
{
|
||||
type: 'license_fee',
|
||||
name: 'Base Fee',
|
||||
unit_amount: definition[:base_fee].round(2)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# rubocop:disable Style/ClassAndModuleChildren
|
||||
module Enterprise
|
||||
module Billing
|
||||
module V2
|
||||
class ProrationCalculator
|
||||
# Calculate prorated amounts for subscription changes
|
||||
#
|
||||
# @param old_plan_price [Float] Price per unit of the old plan
|
||||
# @param new_plan_price [Float] Price per unit of the new plan
|
||||
# @param old_quantity [Integer] Old quantity/seats
|
||||
# @param new_quantity [Integer] New quantity/seats
|
||||
# @param next_billing_date [String/Time] Next billing date (ISO 8601)
|
||||
# @return [Hash] { credit_amount:, charge_amount:, net_amount:, days_remaining:, total_days: }
|
||||
#
|
||||
def self.calculate(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
|
||||
new(
|
||||
old_plan_price: old_plan_price,
|
||||
new_plan_price: new_plan_price,
|
||||
old_quantity: old_quantity,
|
||||
new_quantity: new_quantity,
|
||||
next_billing_date: next_billing_date
|
||||
).calculate
|
||||
end
|
||||
|
||||
def initialize(old_plan_price:, new_plan_price:, old_quantity:, new_quantity:, next_billing_date:)
|
||||
@old_plan_price = old_plan_price.to_f
|
||||
@new_plan_price = new_plan_price.to_f
|
||||
@old_quantity = old_quantity.to_i
|
||||
@new_quantity = new_quantity.to_i
|
||||
@next_billing_date = parse_date(next_billing_date)
|
||||
@current_date = Time.zone.now
|
||||
end
|
||||
|
||||
def calculate
|
||||
{
|
||||
credit_amount: credit_amount,
|
||||
charge_amount: charge_amount,
|
||||
net_amount: net_amount,
|
||||
days_remaining: days_remaining,
|
||||
total_days: total_days,
|
||||
proration_factor: proration_factor
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_date(date)
|
||||
return date if date.is_a?(Time) || date.is_a?(DateTime)
|
||||
|
||||
Time.zone.parse(date.to_s)
|
||||
rescue StandardError
|
||||
raise ArgumentError, "Invalid next_billing_date format: #{date}"
|
||||
end
|
||||
|
||||
# Credit from unused time on old plan
|
||||
def credit_amount
|
||||
@credit_amount ||= (@old_plan_price * @old_quantity * proration_factor).round(2)
|
||||
end
|
||||
|
||||
# Charge for new plan for remaining time
|
||||
def charge_amount
|
||||
@charge_amount ||= (@new_plan_price * @new_quantity * proration_factor).round(2)
|
||||
end
|
||||
|
||||
# Net amount to charge (positive) or credit (negative)
|
||||
def net_amount
|
||||
@net_amount ||= (charge_amount - credit_amount).round(2)
|
||||
end
|
||||
|
||||
# Number of days remaining in current billing period
|
||||
def days_remaining
|
||||
@days_remaining ||= (@next_billing_date.to_date - @current_date.to_date).to_i
|
||||
end
|
||||
|
||||
# Total days in the actual billing period (calculate from current cycle)
|
||||
# This ensures proration factor never exceeds 1.0
|
||||
def total_days
|
||||
@total_days ||= begin
|
||||
# Calculate the total days in this billing cycle
|
||||
# Assume billing started one month ago from next_billing_date
|
||||
billing_start = @next_billing_date - 1.month
|
||||
((@next_billing_date.to_date - billing_start.to_date).to_i)
|
||||
end
|
||||
end
|
||||
|
||||
# Fraction of billing period remaining
|
||||
# This value should always be between 0.0 and 1.0
|
||||
def proration_factor
|
||||
@proration_factor ||= begin
|
||||
factor = days_remaining.to_f / total_days
|
||||
# Ensure factor never exceeds 1.0
|
||||
[factor, 1.0].min.round(4)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/ClassAndModuleChildren
|
||||
@@ -0,0 +1,120 @@
|
||||
class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService
|
||||
include Enterprise::Billing::Concerns::PlanFeatureManager
|
||||
include Enterprise::Billing::Concerns::PlanProvisioningHelper
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def provision(subscription_id:)
|
||||
process_subscription(subscription_id)
|
||||
end
|
||||
|
||||
def refresh
|
||||
return if stripe_subscription_id.blank?
|
||||
|
||||
process_subscription(stripe_subscription_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_subscription(subscription_id)
|
||||
# Retrieve pricing plan subscription details from Stripe V2 API
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
|
||||
# Check if subscription is canceled
|
||||
if servicing_status(subscription) == 'canceled'
|
||||
cancel_subscription
|
||||
reset_captain_usage
|
||||
return { pricing_plan_id: nil, quantity: nil }
|
||||
end
|
||||
|
||||
# Extract details from the subscription
|
||||
pricing_plan_id = extract_pricing_plan_id(subscription)
|
||||
quantity = extract_subscription_quantity(subscription)
|
||||
billing_cadence = extract_billing_cadence(subscription)
|
||||
|
||||
# Update account with subscription details
|
||||
update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
|
||||
# Provision the subscription: sync credits and enable features
|
||||
provision_new_plan(pricing_plan_id) if pricing_plan_id.present?
|
||||
|
||||
# Reset usage for the new billing cycle
|
||||
reset_captain_usage
|
||||
|
||||
{ pricing_plan_id: pricing_plan_id, quantity: quantity }
|
||||
end
|
||||
|
||||
def servicing_status(subscription_plan)
|
||||
extract_attribute(subscription_plan, :servicing_status)
|
||||
end
|
||||
|
||||
def cancel_subscription
|
||||
hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID')
|
||||
return if hacker_plan_config.nil?
|
||||
|
||||
pricing_plan_id = hacker_plan_config.value
|
||||
|
||||
# Update subscription status and plan details
|
||||
attributes = {
|
||||
'plan_name': 'Hacker',
|
||||
'stripe_pricing_plan_id': pricing_plan_id,
|
||||
'subscribed_quantity': 2,
|
||||
'stripe_subscription_id': nil,
|
||||
'billing_cadence': nil,
|
||||
'subscription_status': 'canceled'
|
||||
}
|
||||
update_custom_attributes(attributes)
|
||||
|
||||
# Sync credits for Hacker plan (0 credits)
|
||||
Enterprise::Billing::V2::CreditManagementService
|
||||
.new(account: account)
|
||||
.sync_monthly_response_credits(0)
|
||||
|
||||
# Disable all premium features and save
|
||||
disable_all_premium_features
|
||||
account.save!
|
||||
end
|
||||
|
||||
def extract_pricing_plan_id(subscription)
|
||||
extract_attribute(subscription, :pricing_plan)
|
||||
end
|
||||
|
||||
def extract_billing_cadence(subscription)
|
||||
extract_attribute(subscription, :billing_cadence)
|
||||
end
|
||||
|
||||
def extract_subscription_quantity(_subscription)
|
||||
# Get quantity from account custom_attributes (set during checkout)
|
||||
pending_quantity = custom_attribute('pending_subscription_quantity')
|
||||
return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive?
|
||||
|
||||
return subscribed_quantity if subscribed_quantity.positive?
|
||||
|
||||
1
|
||||
end
|
||||
|
||||
def update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence)
|
||||
Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \
|
||||
"pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}"
|
||||
|
||||
attributes = {
|
||||
'stripe_subscription_id' => subscription_id,
|
||||
'subscribed_quantity' => quantity,
|
||||
'subscription_status' => 'active',
|
||||
'pending_subscription_quantity' => nil,
|
||||
'pending_subscription_pricing_plan' => nil,
|
||||
'billing_cadence' => billing_cadence,
|
||||
'next_billing_date' => nil,
|
||||
'pending_stripe_pricing_plan_id' => nil,
|
||||
'stripe_billing_version' => 2
|
||||
}
|
||||
attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present?
|
||||
|
||||
# Add plan name from catalog
|
||||
if pricing_plan_id.present?
|
||||
plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id)
|
||||
attributes['plan_name'] = plan_definition[:display_name] if plan_definition
|
||||
end
|
||||
|
||||
update_custom_attributes(attributes)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
module Enterprise::Billing::V2::TopupCatalog
|
||||
DEFAULT_TOPUPS = [
|
||||
{ credits: 1000, amount: 20.0 },
|
||||
{ credits: 2500, amount: 50.0 },
|
||||
{ credits: 5000, amount: 100.0 },
|
||||
{ credits: 10_000, amount: 200.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,57 @@
|
||||
class Enterprise::Billing::V2::WebhookHandlerService
|
||||
include Enterprise::Billing::Concerns::StripeV2ClientHelper
|
||||
|
||||
def perform(event:)
|
||||
@event = event
|
||||
raise StandardError, 'Event is required' if @event.blank?
|
||||
raise StandardError, 'Account not found' if account.blank?
|
||||
|
||||
case @event.type
|
||||
when 'v2.billing.pricing_plan_subscription.servicing_activated'
|
||||
Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}"
|
||||
handle_subscription_servicing_activated(@event.related_object.id)
|
||||
when 'v2.billing.cadence.billed'
|
||||
Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}"
|
||||
refresh_account_subscription_details(@event.related_object.id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@account ||= begin
|
||||
related_object = @event.related_object
|
||||
subscription_id = related_object.id
|
||||
|
||||
customer_id = fetch_customer_id_from_subscription(subscription_id)
|
||||
found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present?
|
||||
|
||||
Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank?
|
||||
|
||||
found_account
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_customer_id_from_subscription(subscription_id)
|
||||
subscription = retrieve_pricing_plan_subscription(subscription_id)
|
||||
return nil unless subscription&.billing_cadence
|
||||
|
||||
cadence = retrieve_billing_cadence(subscription.billing_cadence)
|
||||
cadence.payer&.customer
|
||||
end
|
||||
|
||||
def handle_subscription_servicing_activated(subscription_id)
|
||||
Enterprise::Billing::V2::SubscriptionProvisioningService
|
||||
.new(account: account)
|
||||
.provision(subscription_id: subscription_id)
|
||||
end
|
||||
|
||||
def refresh_account_subscription_details(_cadence_id)
|
||||
Enterprise::Billing::V2::SubscriptionProvisioningService
|
||||
.new(account: account)
|
||||
.refresh
|
||||
end
|
||||
end
|
||||
@@ -205,8 +205,10 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
},
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
|
||||
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
|
||||
'monthly' => nil, 'topup' => nil },
|
||||
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit,
|
||||
'monthly' => 0, 'topup' => 0 }
|
||||
},
|
||||
'non_web_inboxes' => {}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,34 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
describe 'POST /enterprise/webhooks/stripe' do
|
||||
let(:params) { { content: 'hello' } }
|
||||
let(:payload) { { type: 'customer.subscription.updated', content: 'hello' }.to_json }
|
||||
let(:event_object) { instance_double(Stripe::Event, type: 'customer.subscription.updated') }
|
||||
|
||||
around do |example|
|
||||
ENV['STRIPE_WEBHOOK_SECRET'] = 'test_secret'
|
||||
ENV['STRIPE_WEBHOOK_SECRET_V2'] = 'test_secret_v2'
|
||||
example.run
|
||||
ENV.delete('STRIPE_WEBHOOK_SECRET')
|
||||
ENV.delete('STRIPE_WEBHOOK_SECRET_V2')
|
||||
end
|
||||
|
||||
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
|
||||
handle_stripe = double
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(event_object)
|
||||
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
|
||||
allow(handle_stripe).to receive(:perform)
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
expect(handle_stripe).to have_received(:perform).with(event: params)
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
|
||||
expect(handle_stripe).to have_received(:perform).with(event: event_object)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are missing' do
|
||||
post '/enterprise/webhooks/stripe', params: params
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are invalid' do
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_raise(Stripe::SignatureVerificationError.new('Invalid signature', 'sig'))
|
||||
post '/enterprise/webhooks/stripe', params: payload, headers: { 'Content-Type' => 'application/json', 'Stripe-Signature' => 'test' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
+78
-118
@@ -1,139 +1,99 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Enterprise::Billing::CreateStripeCustomerService do
|
||||
subject(:create_stripe_customer_service) { described_class }
|
||||
subject(:service) { described_class.new(account: account) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:admin1) { create(:user, account: account, role: :administrator) }
|
||||
let(:admin2) { create(:user, account: account, role: :administrator) }
|
||||
let(:subscriptions_list) { double }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:stripe_customer_double) { instance_double(Stripe::Customer, id: 'cus_new') }
|
||||
let(:subscriptions_list) { Stripe::ListObject.construct_from({ data: [] }) }
|
||||
let(:hacker_plan_id) { 'price_hacker_random' }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not call stripe methods if customer id is present' do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
|
||||
allow(subscriptions_list).to receive(:data).and_return([])
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
name: 'CHATWOOT_CLOUD_PLANS',
|
||||
value: [
|
||||
{
|
||||
stripe_customer_id: 'cus_random_number',
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
'name' => 'Hacker',
|
||||
'limits' => {
|
||||
'captain_responses_monthly' => 5,
|
||||
'captain_responses_topup' => 0
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
it 'calls stripe methods to create a customer and updates the account' do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription)
|
||||
.to receive(:create)
|
||||
.and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
|
||||
expect(Stripe::Subscription)
|
||||
.to have_received(:create)
|
||||
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
|
||||
|
||||
expect(account.reload.custom_attributes).to eq(
|
||||
{
|
||||
stripe_customer_id: customer.id,
|
||||
stripe_price_id: 'price_random_number',
|
||||
stripe_product_id: 'prod_random_number',
|
||||
subscribed_quantity: 2,
|
||||
plan_name: 'A Plan Name'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
create(:installation_config, name: 'STRIPE_HACKER_PLAN_ID', value: hacker_plan_id)
|
||||
end
|
||||
|
||||
describe 'when checking for existing subscriptions' do
|
||||
before do
|
||||
create(
|
||||
:installation_config,
|
||||
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
|
||||
] }
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account has no stripe_customer_id' do
|
||||
it 'creates a new subscription' do
|
||||
customer = double
|
||||
allow(Stripe::Customer).to receive(:create).and_return(customer)
|
||||
allow(customer).to receive(:id).and_return('cus_random_number')
|
||||
allow(Stripe::Subscription).to receive(:create).and_return(
|
||||
{
|
||||
plan: { id: 'price_random_number', product: 'prod_random_number' },
|
||||
quantity: 2
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:create)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has stripe_customer_id' do
|
||||
let(:stripe_customer_id) { 'cus_random_number' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when account already has an active subscription' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
|
||||
allow(Stripe::Subscription).to receive(:list)
|
||||
.and_return(Stripe::ListObject.construct_from({ data: ['subscription'] }))
|
||||
end
|
||||
|
||||
context 'when customer has active subscriptions' do
|
||||
it 'returns without modifying the account or contacting Stripe' do
|
||||
expect(Stripe::Customer).not_to receive(:create)
|
||||
|
||||
expect { service.perform }.not_to(change { account.reload.custom_attributes })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when v2 billing configuration is missing' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').destroy!
|
||||
end
|
||||
|
||||
it 'raises an informative error' do
|
||||
expect { service.perform }.to raise_error(
|
||||
StandardError,
|
||||
'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account needs to be upgraded to v2 billing' do
|
||||
context 'when stripe customer already exists' do
|
||||
before do
|
||||
account.update!(custom_attributes: { stripe_customer_id: 'cus_existing' })
|
||||
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
|
||||
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
|
||||
allow(Stripe::Subscription).to receive(:create)
|
||||
allow(Stripe::Customer).to receive(:create)
|
||||
end
|
||||
|
||||
it 'does not create a new subscription' do
|
||||
create_stripe_customer_service.new(account: account).perform
|
||||
it 'does not create a new customer but updates custom attributes for v2 billing' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Subscription).not_to have_received(:create)
|
||||
expect(Stripe::Subscription).to have_received(:list).with(
|
||||
{
|
||||
customer: stripe_customer_id,
|
||||
status: 'active',
|
||||
limit: 1
|
||||
}
|
||||
expect(Stripe::Customer).not_to have_received(:create)
|
||||
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_existing',
|
||||
'stripe_pricing_plan_id' => hacker_plan_id,
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
|
||||
'stripe_billing_version' => 2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when stripe customer does not exist' do
|
||||
before do
|
||||
allow(Stripe::Customer).to receive(:create).and_return(stripe_customer_double)
|
||||
end
|
||||
|
||||
it 'creates a stripe customer and updates the account with v2 billing attributes' do
|
||||
service.perform
|
||||
|
||||
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin.email })
|
||||
|
||||
expect(account.reload.custom_attributes).to include(
|
||||
'stripe_customer_id' => 'cus_new',
|
||||
'stripe_pricing_plan_id' => hacker_plan_id,
|
||||
'plan_name' => 'Hacker',
|
||||
'subscribed_quantity' => described_class::DEFAULT_QUANTITY,
|
||||
'stripe_billing_version' => 2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -317,4 +317,146 @@ describe Enterprise::Billing::HandleStripeEventService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'credit grant handling' do
|
||||
let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) }
|
||||
|
||||
before do
|
||||
allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new)
|
||||
.with(account: account).and_return(credit_service)
|
||||
end
|
||||
|
||||
context 'when handling monthly credit grant' do
|
||||
it 'adds credits from Stripe' do
|
||||
allow(credit_service).to receive(:sync_monthly_response_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_123',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '2000' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 2000)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_123')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_response_credits).with(2000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling topup credit grant' do
|
||||
it 'adds topup credits' do
|
||||
allow(credit_service).to receive(:sync_monthly_response_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API (has complete amount structure)
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_456',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '500' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 500)
|
||||
),
|
||||
expires_at: nil
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_456')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:sync_monthly_response_credits).with(500)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling monetary type credit grant' do
|
||||
it 'adds credits from monetary grant' do
|
||||
allow(credit_service).to receive(:add_response_topup_credits)
|
||||
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with monetary amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_monetary',
|
||||
customer: 'cus_123',
|
||||
metadata: { 'credits' => '1000' },
|
||||
amount: OpenStruct.new(
|
||||
type: 'monetary',
|
||||
monetary: OpenStruct.new(
|
||||
currency: 'usd',
|
||||
value: 1000
|
||||
)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_monetary')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
expect(credit_service).to have_received(:add_response_topup_credits).with(1000)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling credit grant with zero amount' do
|
||||
it 'does not call credit service' do
|
||||
# Webhook event object (minimal, just has ID)
|
||||
grant_event_object = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123'
|
||||
)
|
||||
allow(event).to receive(:type).and_return('billing.credit_grant.created')
|
||||
allow(data).to receive(:object).and_return(grant_event_object)
|
||||
|
||||
# Full grant object from API with zero amount
|
||||
api_grant_response = OpenStruct.new(
|
||||
id: 'credgr_test_zero',
|
||||
customer: 'cus_123',
|
||||
amount: OpenStruct.new(
|
||||
type: 'custom_pricing_unit',
|
||||
custom_pricing_unit: OpenStruct.new(value: 0)
|
||||
),
|
||||
expires_at: Time.current
|
||||
)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:retrieve)
|
||||
.with('credgr_test_zero')
|
||||
.and_return(api_grant_response)
|
||||
|
||||
stripe_event_service.new.perform(event: event)
|
||||
|
||||
# Ensure we don't accidentally call these methods
|
||||
expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user