From eb759255d87d489decfefed37bb483fb4726fc91 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:52:17 +0530 Subject: [PATCH] perf: update the logic to purchase credits (#12998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 image image ## 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 --- .../api/enterprise/specs/account.spec.js | 26 +++ .../dashboard/i18n/locale/en/settings.json | 10 +- .../dashboard/settings/billing/Index.vue | 10 +- .../components/PurchaseCreditsModal.vue | 184 +++++++++++++----- config/locales/en.yml | 2 + config/routes.rb | 1 - .../enterprise/api/v1/accounts_controller.rb | 13 +- .../billing/handle_stripe_event_service.rb | 27 --- .../billing/topup_checkout_service.rb | 106 +++++----- .../billing/topup_fulfillment_service.rb | 2 + .../api/v1/accounts_controller_spec.rb | 72 +++++++ .../billing/topup_checkout_service_spec.rb | 60 ++++++ .../billing/topup_fulfillment_service_spec.rb | 36 ++++ 13 files changed, 414 insertions(+), 135 deletions(-) create mode 100644 spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/topup_fulfillment_service_spec.rb diff --git a/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/app/javascript/dashboard/api/enterprise/specs/account.spec.js index 9c65b0b67..47d2eb26d 100644 --- a/app/javascript/dashboard/api/enterprise/specs/account.spec.js +++ b/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => { expect(accountAPI).toHaveProperty('delete'); expect(accountAPI).toHaveProperty('checkout'); expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); }); describe('API calls', () => { @@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => { { 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'); + }); }); }); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 64be235a2..4e51ef37a 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -422,7 +422,15 @@ "PURCHASE": "Purchase Credits", "LOADING": "Loading options...", "FETCH_ERROR": "Failed to load credit options. Please try again.", - "PURCHASE_ERROR": "Failed to process purchase. Please try again." + "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": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue index 4f0981967..bcfa46193 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/Index.vue @@ -132,6 +132,11 @@ const openPurchaseCreditsModal = () => { purchaseCreditsModalRef.value?.open(); }; +const handleTopupSuccess = () => { + // Refresh limits to show updated credit balance + fetchLimits(); +}; + onMounted(handleBillingPageLogic); @@ -254,7 +259,10 @@ onMounted(handleBillingPageLogic); - + diff --git a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue index ebbab0aa8..33f299b9b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/billing/components/PurchaseCreditsModal.vue @@ -7,7 +7,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import CreditPackageCard from './CreditPackageCard.vue'; import EnterpriseAccountAPI from 'dashboard/api/enterprise/account'; -const emit = defineEmits(['close']); +const emit = defineEmits(['close', 'success']); const { t } = useI18n(); @@ -19,19 +19,78 @@ const TOPUP_OPTIONS = [ ]; const POPULAR_CREDITS_AMOUNT = 6000; +const STEP_SELECT = 'select'; +const STEP_CONFIRM = 'confirm'; const dialogRef = ref(null); const selectedCredits = ref(null); const isLoading = ref(false); +const currentStep = ref(STEP_SELECT); const selectedOption = computed(() => { 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 => { 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 () => { if (!selectedOption.value) return; @@ -40,9 +99,14 @@ const handlePurchase = async () => { const response = await EnterpriseAccountAPI.createTopupCheckout( 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) { const errorMessage = error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR'); @@ -52,61 +116,71 @@ 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 });