perf: update the logic to purchase credits (#12998)
## Description - Replaces Stripe Checkout session flow with direct card charging for AI credit top-ups - Adds a two-step confirmation modal (select package → confirm purchase) for better UX - Creates Stripe invoice directly and charges the customer's default payment method immediately ## Type of change - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Using the specs - UI manual test cases <img width="945" height="580" alt="image" src="https://github.com/user-attachments/assets/52bdad46-cd0e-4927-b13f-54c6b6353bcc" /> <img width="945" height="580" alt="image" src="https://github.com/user-attachments/assets/231bc7e9-41ac-440d-a93d-cba45a4d3e3e" /> ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
committed by
GitHub
parent
cc86b8c7f1
commit
eb759255d8
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
expect(accountAPI).toHaveProperty('delete');
|
expect(accountAPI).toHaveProperty('delete');
|
||||||
expect(accountAPI).toHaveProperty('checkout');
|
expect(accountAPI).toHaveProperty('checkout');
|
||||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||||
|
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||||
|
expect(accountAPI).toHaveProperty('getLimits');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('API calls', () => {
|
describe('API calls', () => {
|
||||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
|||||||
{ action_type: 'undelete' }
|
{ action_type: 'undelete' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with credits', () => {
|
||||||
|
accountAPI.createTopupCheckout(1000);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#createTopupCheckout with different credit amounts', () => {
|
||||||
|
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||||
|
creditAmounts.forEach(credits => {
|
||||||
|
accountAPI.createTopupCheckout(credits);
|
||||||
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
'/enterprise/api/v1/topup_checkout',
|
||||||
|
{ credits }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#getLimits', () => {
|
||||||
|
accountAPI.getLimits();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -422,7 +422,15 @@
|
|||||||
"PURCHASE": "Purchase Credits",
|
"PURCHASE": "Purchase Credits",
|
||||||
"LOADING": "Loading options...",
|
"LOADING": "Loading options...",
|
||||||
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||||
"PURCHASE_ERROR": "Failed to process purchase. Please try again."
|
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
||||||
|
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
||||||
|
"CONFIRM": {
|
||||||
|
"TITLE": "Confirm Purchase",
|
||||||
|
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
|
||||||
|
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
|
||||||
|
"GO_BACK": "Go Back",
|
||||||
|
"CONFIRM_PURCHASE": "Confirm Purchase"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SECURITY_SETTINGS": {
|
"SECURITY_SETTINGS": {
|
||||||
|
|||||||
@@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => {
|
|||||||
purchaseCreditsModalRef.value?.open();
|
purchaseCreditsModalRef.value?.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTopupSuccess = () => {
|
||||||
|
// Refresh limits to show updated credit balance
|
||||||
|
fetchLimits();
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(handleBillingPageLogic);
|
onMounted(handleBillingPageLogic);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic);
|
|||||||
</ButtonV4>
|
</ButtonV4>
|
||||||
</BillingHeader>
|
</BillingHeader>
|
||||||
</section>
|
</section>
|
||||||
<PurchaseCreditsModal ref="purchaseCreditsModalRef" />
|
<PurchaseCreditsModal
|
||||||
|
ref="purchaseCreditsModalRef"
|
||||||
|
@success="handleTopupSuccess"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import CreditPackageCard from './CreditPackageCard.vue';
|
import CreditPackageCard from './CreditPackageCard.vue';
|
||||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close', 'success']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const POPULAR_CREDITS_AMOUNT = 6000;
|
const POPULAR_CREDITS_AMOUNT = 6000;
|
||||||
|
const STEP_SELECT = 'select';
|
||||||
|
const STEP_CONFIRM = 'confirm';
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const selectedCredits = ref(null);
|
const selectedCredits = ref(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const currentStep = ref(STEP_SELECT);
|
||||||
|
|
||||||
const selectedOption = computed(() => {
|
const selectedOption = computed(() => {
|
||||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formattedAmount = computed(() => {
|
||||||
|
if (!selectedOption.value) return '';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: selectedOption.value.currency.toUpperCase(),
|
||||||
|
}).format(selectedOption.value.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedCredits = computed(() => {
|
||||||
|
if (!selectedOption.value) return '';
|
||||||
|
return selectedOption.value.credits.toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
return currentStep.value === STEP_SELECT
|
||||||
|
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
|
||||||
|
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogDescription = computed(() => {
|
||||||
|
return currentStep.value === STEP_SELECT
|
||||||
|
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogWidth = computed(() => {
|
||||||
|
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
|
||||||
|
});
|
||||||
|
|
||||||
const handlePackageSelect = credits => {
|
const handlePackageSelect = credits => {
|
||||||
selectedCredits.value = credits;
|
selectedCredits.value = credits;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
const popularOption = TOPUP_OPTIONS.find(
|
||||||
|
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||||
|
);
|
||||||
|
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||||
|
currentStep.value = STEP_SELECT;
|
||||||
|
isLoading.value = false;
|
||||||
|
dialogRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToConfirmStep = () => {
|
||||||
|
if (!selectedOption.value) return;
|
||||||
|
currentStep.value = STEP_CONFIRM;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBackToSelectStep = () => {
|
||||||
|
currentStep.value = STEP_SELECT;
|
||||||
|
};
|
||||||
|
|
||||||
const handlePurchase = async () => {
|
const handlePurchase = async () => {
|
||||||
if (!selectedOption.value) return;
|
if (!selectedOption.value) return;
|
||||||
|
|
||||||
@@ -40,9 +99,14 @@ const handlePurchase = async () => {
|
|||||||
const response = await EnterpriseAccountAPI.createTopupCheckout(
|
const response = await EnterpriseAccountAPI.createTopupCheckout(
|
||||||
selectedOption.value.credits
|
selectedOption.value.credits
|
||||||
);
|
);
|
||||||
if (response.data.redirect_url) {
|
|
||||||
window.location.href = response.data.redirect_url;
|
close();
|
||||||
}
|
emit('success', response.data);
|
||||||
|
useAlert(
|
||||||
|
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
|
||||||
|
credits: response.data.credits,
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
|
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
|
||||||
@@ -52,36 +116,21 @@ const handlePurchase = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
|
|
||||||
const open = () => {
|
|
||||||
// Pre-select the most popular option
|
|
||||||
const popularOption = TOPUP_OPTIONS.find(
|
|
||||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
|
||||||
);
|
|
||||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
|
||||||
dialogRef.value?.open();
|
|
||||||
};
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
dialogRef.value?.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({ open, close });
|
defineExpose({ open, close });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
:title="$t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')"
|
:title="dialogTitle"
|
||||||
:description="$t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')"
|
:description="dialogDescription"
|
||||||
width="xl"
|
:width="dialogWidth"
|
||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
|
<!-- Step 1: Select Credits Package -->
|
||||||
|
<template v-if="currentStep === 'select'">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<CreditPackageCard
|
<CreditPackageCard
|
||||||
v-for="option in TOPUP_OPTIONS"
|
v-for="option in TOPUP_OPTIONS"
|
||||||
@@ -104,9 +153,34 @@ defineExpose({ open, close });
|
|||||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Step 2: Confirm Purchase -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
|
||||||
|
credits: formattedCredits,
|
||||||
|
amount: formattedAmount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
|
||||||
|
<p class="text-sm text-n-amber-11">
|
||||||
|
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex items-center justify-between w-full gap-3">
|
<!-- Step 1 Footer -->
|
||||||
|
<div
|
||||||
|
v-if="currentStep === 'select'"
|
||||||
|
class="flex items-center justify-between w-full gap-3"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
@@ -119,6 +193,24 @@ defineExpose({ open, close });
|
|||||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="!selectedCredits"
|
:disabled="!selectedCredits"
|
||||||
|
@click="goToConfirmStep"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 Footer -->
|
||||||
|
<div v-else class="flex items-center justify-between w-full gap-3">
|
||||||
|
<Button
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="goBackToSelectStep"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
|
||||||
|
class="w-full"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="handlePurchase"
|
@click="handlePurchase"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ en:
|
|||||||
invalid_credits: Invalid credits amount
|
invalid_credits: Invalid credits amount
|
||||||
invalid_option: Invalid topup option
|
invalid_option: Invalid topup option
|
||||||
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||||
|
stripe_customer_not_configured: Stripe customer not configured
|
||||||
|
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
||||||
profile:
|
profile:
|
||||||
mfa:
|
mfa:
|
||||||
enabled: MFA enabled successfully
|
enabled: MFA enabled successfully
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ Rails.application.routes.draw do
|
|||||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
|
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
|
||||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
||||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
||||||
get '/app/accounts/:account_id/settings/billing', to: 'dashboard#index', as: 'app_account_billing_settings'
|
|
||||||
|
|
||||||
resource :widget, only: [:show]
|
resource :widget, only: [:show]
|
||||||
namespace :survey do
|
namespace :survey do
|
||||||
|
|||||||
@@ -59,10 +59,15 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
|
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
|
||||||
|
|
||||||
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
|
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
|
||||||
redirect_url = service.create_checkout_session(credits: params[:credits].to_i)
|
result = service.create_checkout_session(credits: params[:credits].to_i)
|
||||||
render json: { redirect_url: redirect_url }
|
|
||||||
rescue Enterprise::Billing::TopupCheckoutService::Error => e
|
@account.reload
|
||||||
Rails.logger.error("Topup checkout failed for account #{@account.id}: #{e.message}")
|
render json: result.merge(
|
||||||
|
id: @account.id,
|
||||||
|
limits: @account.limits,
|
||||||
|
custom_attributes: @account.custom_attributes
|
||||||
|
)
|
||||||
|
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
|
||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
process_subscription_updated
|
process_subscription_updated
|
||||||
when 'customer.subscription.deleted'
|
when 'customer.subscription.deleted'
|
||||||
process_subscription_deleted
|
process_subscription_deleted
|
||||||
when 'checkout.session.completed'
|
|
||||||
process_checkout_session_completed
|
|
||||||
else
|
else
|
||||||
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
Rails.logger.debug { "Unhandled event type: #{event.type}" }
|
||||||
end
|
end
|
||||||
@@ -92,31 +90,6 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_checkout_session_completed
|
|
||||||
session = @event.data.object
|
|
||||||
metadata = session.metadata
|
|
||||||
|
|
||||||
# Only process topup checkout sessions
|
|
||||||
return unless metadata.present? && metadata['topup'] == 'true'
|
|
||||||
|
|
||||||
topup_account = Account.find_by(id: metadata['account_id'])
|
|
||||||
return if topup_account.blank?
|
|
||||||
|
|
||||||
credits = metadata['credits'].to_i
|
|
||||||
amount_cents = metadata['amount_cents'].to_i
|
|
||||||
currency = metadata['currency'] || 'usd'
|
|
||||||
|
|
||||||
Rails.logger.info("Processing topup for account #{topup_account.id}: #{credits} credits, #{amount_cents} cents")
|
|
||||||
Enterprise::Billing::TopupFulfillmentService.new(account: topup_account).fulfill(
|
|
||||||
credits: credits,
|
|
||||||
amount_cents: amount_cents,
|
|
||||||
currency: currency
|
|
||||||
)
|
|
||||||
rescue StandardError => e
|
|
||||||
ChatwootExceptionTracker.new(e, account: topup_account).capture_exception
|
|
||||||
raise
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_plan_features
|
def update_plan_features
|
||||||
if default_plan?
|
if default_plan?
|
||||||
disable_all_premium_features
|
disable_all_premium_features
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
class Enterprise::Billing::TopupCheckoutService
|
class Enterprise::Billing::TopupCheckoutService
|
||||||
include BillingHelper
|
include BillingHelper
|
||||||
include Rails.application.routes.url_helpers
|
|
||||||
|
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
@@ -15,8 +14,14 @@ class Enterprise::Billing::TopupCheckoutService
|
|||||||
|
|
||||||
def create_checkout_session(credits:)
|
def create_checkout_session(credits:)
|
||||||
topup_option = validate_and_find_topup_option(credits)
|
topup_option = validate_and_find_topup_option(credits)
|
||||||
session = create_stripe_session(topup_option, credits)
|
charge_customer(topup_option, credits)
|
||||||
session.url
|
fulfill_credits(credits, topup_option)
|
||||||
|
|
||||||
|
{
|
||||||
|
credits: credits,
|
||||||
|
amount: topup_option[:amount],
|
||||||
|
currency: topup_option[:currency]
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -24,69 +29,60 @@ class Enterprise::Billing::TopupCheckoutService
|
|||||||
def validate_and_find_topup_option(credits)
|
def validate_and_find_topup_option(credits)
|
||||||
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
|
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
|
||||||
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
|
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
|
||||||
|
raise Error, I18n.t('errors.topup.stripe_customer_not_configured') if stripe_customer_id.blank?
|
||||||
|
|
||||||
topup_option = find_topup_option(credits)
|
topup_option = find_topup_option(credits)
|
||||||
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
|
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
|
||||||
|
|
||||||
|
# Validate payment method exists
|
||||||
|
validate_payment_method!
|
||||||
|
|
||||||
topup_option
|
topup_option
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_stripe_session(topup_option, credits)
|
def validate_payment_method!
|
||||||
Stripe::Checkout::Session.create(
|
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||||
|
|
||||||
|
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
|
||||||
|
|
||||||
|
# Auto-set first payment method as default if available
|
||||||
|
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
|
||||||
|
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
|
||||||
|
|
||||||
|
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
|
||||||
|
end
|
||||||
|
|
||||||
|
def charge_customer(topup_option, credits)
|
||||||
|
amount_cents = (topup_option[:amount] * 100).to_i
|
||||||
|
currency = topup_option[:currency]
|
||||||
|
description = "AI Credits Topup: #{credits} credits"
|
||||||
|
|
||||||
|
invoice = Stripe::Invoice.create(
|
||||||
customer: stripe_customer_id,
|
customer: stripe_customer_id,
|
||||||
mode: 'payment',
|
currency: currency,
|
||||||
line_items: [build_line_item(topup_option, credits)],
|
collection_method: 'charge_automatically',
|
||||||
success_url: success_url,
|
auto_advance: false,
|
||||||
cancel_url: cancel_url,
|
description: description
|
||||||
metadata: session_metadata(credits, topup_option),
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
# Show saved payment methods and allow saving new ones
|
|
||||||
saved_payment_method_options: {
|
|
||||||
payment_method_save: 'enabled',
|
|
||||||
allow_redisplay_filters: %w[always limited]
|
|
||||||
},
|
|
||||||
# Create invoice for this payment so it appears in customer portal
|
|
||||||
invoice_creation: build_invoice_creation_data(credits, topup_option)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Stripe::InvoiceItem.create(
|
||||||
|
customer: stripe_customer_id,
|
||||||
|
amount: amount_cents,
|
||||||
|
currency: currency,
|
||||||
|
invoice: invoice.id,
|
||||||
|
description: description
|
||||||
|
)
|
||||||
|
|
||||||
|
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
|
||||||
|
Stripe::Invoice.pay(invoice.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_invoice_creation_data(credits, topup_option)
|
def fulfill_credits(credits, topup_option)
|
||||||
{
|
Enterprise::Billing::TopupFulfillmentService.new(account: account).fulfill(
|
||||||
enabled: true,
|
credits: credits,
|
||||||
invoice_data: {
|
amount_cents: (topup_option[:amount] * 100).to_i,
|
||||||
description: "AI Credits Topup: #{credits} credits",
|
currency: topup_option[:currency]
|
||||||
metadata: session_metadata(credits, topup_option)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_line_item(topup_option, credits)
|
|
||||||
{
|
|
||||||
price_data: {
|
|
||||||
currency: topup_option[:currency],
|
|
||||||
unit_amount: (topup_option[:amount] * 100).to_i,
|
|
||||||
product_data: { name: "AI Credits Topup: #{credits} credits" }
|
|
||||||
},
|
|
||||||
quantity: 1
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def session_metadata(credits, topup_option)
|
|
||||||
{
|
|
||||||
account_id: account.id.to_s,
|
|
||||||
credits: credits.to_s,
|
|
||||||
amount_cents: (topup_option[:amount] * 100).to_s,
|
|
||||||
currency: topup_option[:currency],
|
|
||||||
topup: 'true'
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def success_url
|
|
||||||
app_account_billing_settings_url(account_id: account.id, topup: 'success')
|
|
||||||
end
|
|
||||||
|
|
||||||
def cancel_url
|
|
||||||
app_account_billing_settings_url(account_id: account.id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe_customer_id
|
def stripe_customer_id
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class Enterprise::Billing::TopupFulfillmentService
|
|||||||
create_stripe_credit_grant(credits, amount_cents, currency)
|
create_stripe_credit_grant(credits, amount_cents, currency)
|
||||||
update_account_credits(credits)
|
update_account_credits(credits)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rails.logger.info("Topup fulfilled for account #{account.id}: #{credits} credits, #{amount_cents} cents")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -245,6 +245,78 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST /enterprise/api/v1/accounts/{account.id}/topup_checkout' do
|
||||||
|
let(:stripe_customer_id) { 'cus_test123' }
|
||||||
|
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test123') }
|
||||||
|
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
|
||||||
|
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||||
|
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||||
|
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unauthorized for unauthenticated user' do
|
||||||
|
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout", as: :json
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unauthorized for agent' do
|
||||||
|
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: { credits: 1000 },
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
before do
|
||||||
|
account.update!(
|
||||||
|
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
||||||
|
limits: { 'captain_responses' => 1000 }
|
||||||
|
)
|
||||||
|
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
|
||||||
|
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||||
|
allow(Stripe::InvoiceItem).to receive(:create)
|
||||||
|
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||||
|
allow(Stripe::Invoice).to receive(:pay)
|
||||||
|
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'successfully processes topup and returns correct response' do
|
||||||
|
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { credits: 1000 },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
json_response = JSON.parse(response.body)
|
||||||
|
expect(json_response['credits']).to eq(1000)
|
||||||
|
expect(json_response['amount']).to eq(20.0)
|
||||||
|
expect(json_response['limits']['captain_responses']).to eq(2000)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error when credits parameter is missing' do
|
||||||
|
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns error for invalid credits amount' do
|
||||||
|
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
params: { credits: 999 },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
|
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
|
||||||
context 'when it is an unauthenticated user' do
|
context 'when it is an unauthenticated user' do
|
||||||
it 'returns unauthorized' do
|
it 'returns unauthorized' do
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Enterprise::Billing::TopupCheckoutService do
|
||||||
|
subject(:service) { described_class.new(account: account) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:stripe_customer_id) { 'cus_test123' }
|
||||||
|
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test') }
|
||||||
|
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
|
||||||
|
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||||
|
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||||
|
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||||
|
])
|
||||||
|
|
||||||
|
account.update!(
|
||||||
|
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
||||||
|
limits: { 'captain_responses' => 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer)
|
||||||
|
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||||
|
allow(Stripe::InvoiceItem).to receive(:create)
|
||||||
|
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||||
|
allow(Stripe::Invoice).to receive(:pay)
|
||||||
|
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#create_checkout_session' do
|
||||||
|
it 'successfully processes topup and returns correct response' do
|
||||||
|
result = service.create_checkout_session(credits: 1000)
|
||||||
|
|
||||||
|
expect(result[:credits]).to eq(1000)
|
||||||
|
expect(result[:amount]).to eq(20.0)
|
||||||
|
expect(result[:currency]).to eq('usd')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates account limits after successful topup' do
|
||||||
|
service.create_checkout_session(credits: 1000)
|
||||||
|
|
||||||
|
expect(account.reload.limits['captain_responses']).to eq(1500)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for invalid credits' do
|
||||||
|
expect do
|
||||||
|
service.create_checkout_session(credits: 500)
|
||||||
|
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error when account is on free plan' do
|
||||||
|
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
|
||||||
|
|
||||||
|
expect do
|
||||||
|
service.create_checkout_session(credits: 1000)
|
||||||
|
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Enterprise::Billing::TopupFulfillmentService do
|
||||||
|
subject(:service) { described_class.new(account: account) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:stripe_customer_id) { 'cus_test123' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
account.update!(
|
||||||
|
custom_attributes: { stripe_customer_id: stripe_customer_id },
|
||||||
|
limits: { 'captain_responses' => 1000 }
|
||||||
|
)
|
||||||
|
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#fulfill' do
|
||||||
|
it 'adds credits to account limits' do
|
||||||
|
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
|
||||||
|
|
||||||
|
expect(account.reload.limits['captain_responses']).to eq(2000)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a Stripe credit grant' do
|
||||||
|
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
|
||||||
|
|
||||||
|
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
|
||||||
|
hash_including(
|
||||||
|
customer: stripe_customer_id,
|
||||||
|
name: 'Topup: 1000 credits',
|
||||||
|
category: 'paid'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user