Compare commits

...
Author SHA1 Message Date
Muhsin feb2045e2e chore: prefill billing modal with fetched details 2026-03-30 14:50:29 +04:00
Muhsin c147a82221 feat: verify billing details before checkout 2026-03-28 16:09:30 +04:00
7 changed files with 326 additions and 3 deletions
@@ -27,6 +27,18 @@ class EnterpriseAccountAPI extends ApiClient {
createTopupCheckout(credits) {
return axios.post(`${this.url}topup_checkout`, { credits });
}
getBillingDetails() {
return axios.get(`${this.url}billing_details`);
}
confirmBillingDetails({ businessName, businessAddress, billingEmail }) {
return axios.post(`${this.url}confirm_billing_details`, {
business_name: businessName,
business_address: businessAddress,
billing_email: billingEmail,
});
}
}
export default new EnterpriseAccountAPI();
@@ -483,6 +483,31 @@
"GO_BACK": "Go Back",
"CONFIRM_PURCHASE": "Confirm Purchase"
}
},
"CONFIRM_BUSINESS": {
"TITLE": "Confirm Business Details",
"DESCRIPTION": "Please confirm your business information before accessing the billing portal.",
"PREFILLED_BANNER": "We found your existing business details. Review and update if needed, or continue to proceed.",
"BUSINESS_NAME": {
"LABEL": "Business Name",
"PLACEHOLDER": "Enter your business name",
"REQUIRED": "Business name is required"
},
"BUSINESS_ADDRESS": {
"LABEL": "Business Address",
"PLACEHOLDER": "Enter your business address",
"REQUIRED": "Business address is required"
},
"BILLING_EMAIL": {
"LABEL": "Billing Email",
"PLACEHOLDER": "Enter your billing email",
"REQUIRED": "Billing email is required",
"INVALID": "Please enter a valid email address",
"HELP_TEXT": "All billing-related emails will be sent to this address"
},
"CANCEL": "Cancel",
"CONTINUE": "Continue to Billing Portal",
"ERROR": "Failed to update business details. Please try again."
}
},
"SECURITY_SETTINGS": {
@@ -1,17 +1,21 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { format } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import sessionStorage from 'shared/helpers/sessionStorage';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
import BillingMeter from './components/BillingMeter.vue';
import BillingCard from './components/BillingCard.vue';
import BillingHeader from './components/BillingHeader.vue';
import DetailItem from './components/DetailItem.vue';
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
import ConfirmBusinessDetailsModal from './components/ConfirmBusinessDetailsModal.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ButtonV4 from 'next/button/Button.vue';
@@ -27,6 +31,7 @@ const {
isFetchingLimits,
} = useCaptain();
const { t } = useI18n();
const uiFlags = useMapGetter('accounts/getUIFlags');
const store = useStore();
@@ -35,11 +40,16 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
// State for handling refresh attempts and loading
const isWaitingForBilling = ref(false);
const purchaseCreditsModalRef = ref(null);
const confirmBusinessDetailsModalRef = ref(null);
const customAttributes = computed(() => {
return currentAccount.value.custom_attributes || {};
});
const billingDetailsConfirmed = computed(() => {
return customAttributes.value.billing_details_confirmed;
});
/**
* Computed property for plan name
* @returns {string|undefined}
@@ -118,8 +128,30 @@ const handleBillingPageLogic = async () => {
}
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
const billingDetails = ref({});
const isFetchingBillingDetails = ref(false);
const onClickBillingPortal = async () => {
if (billingDetailsConfirmed.value) {
store.dispatch('accounts/checkout');
return;
}
isFetchingBillingDetails.value = true;
try {
const { data } = await EnterpriseAccountAPI.getBillingDetails();
billingDetails.value = data;
await nextTick();
confirmBusinessDetailsModalRef.value?.open();
} catch {
useAlert(t('BILLING_SETTINGS.CONFIRM_BUSINESS.ERROR'));
} finally {
isFetchingBillingDetails.value = false;
}
};
const onBusinessDetailsConfirmed = redirectUrl => {
window.location = redirectUrl;
};
const onToggleChatWindow = () => {
@@ -263,6 +295,11 @@ onMounted(handleBillingPageLogic);
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
<ConfirmBusinessDetailsModal
ref="confirmBusinessDetailsModalRef"
:billing-details="billingDetails"
@confirmed="onBusinessDetailsConfirmed"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,207 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
const props = defineProps({
billingDetails: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['confirmed']);
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const hasPreExistingData = computed(() => {
return Boolean(
props.billingDetails.business_name ||
props.billingDetails.business_address ||
props.billingDetails.billing_email
);
});
const formValues = reactive({
businessName: '',
businessAddress: '',
billingEmail: '',
});
const validationRules = computed(() => ({
businessName: { required },
businessAddress: { required },
billingEmail: { required, email },
}));
const v$ = useVuelidate(validationRules, formValues);
const fieldKeyMap = {
businessName: 'BUSINESS_NAME',
businessAddress: 'BUSINESS_ADDRESS',
billingEmail: 'BILLING_EMAIL',
};
const getErrorMessage = field => {
const fieldState = v$.value[field];
if (!fieldState || !fieldState.$error) return '';
if (fieldState.required?.$invalid) {
return t(
`BILLING_SETTINGS.CONFIRM_BUSINESS.${fieldKeyMap[field]}.REQUIRED`
);
}
if (fieldState.email?.$invalid) {
return t('BILLING_SETTINGS.CONFIRM_BUSINESS.BILLING_EMAIL.INVALID');
}
return '';
};
const open = () => {
v$.value.$reset();
formValues.businessName = props.billingDetails.business_name || '';
formValues.businessAddress = props.billingDetails.business_address || '';
formValues.billingEmail = props.billingDetails.billing_email || '';
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleConfirm = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
isLoading.value = true;
try {
const { data } = await EnterpriseAccountAPI.confirmBillingDetails({
businessName: formValues.businessName,
businessAddress: formValues.businessAddress,
billingEmail: formValues.billingEmail,
});
emit('confirmed', data.redirect_url);
close();
} catch {
useAlert(t('BILLING_SETTINGS.CONFIRM_BUSINESS.ERROR'));
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t('BILLING_SETTINGS.CONFIRM_BUSINESS.TITLE')"
:description="$t('BILLING_SETTINGS.CONFIRM_BUSINESS.DESCRIPTION')"
:show-confirm-button="false"
:show-cancel-button="false"
width="lg"
>
<div class="flex flex-col gap-4">
<div
v-if="hasPreExistingData"
class="flex items-center gap-3 p-3 rounded-lg bg-n-teal-2 border border-n-teal-6"
>
<fluent-icon
icon="checkmark-circle"
size="18"
class="text-n-teal-11 shrink-0"
/>
<p class="text-sm text-n-teal-11">
{{ $t('BILLING_SETTINGS.CONFIRM_BUSINESS.PREFILLED_BANNER') }}
</p>
</div>
<div>
<label class="mb-1 text-heading-3 text-n-slate-12">
{{ $t('BILLING_SETTINGS.CONFIRM_BUSINESS.BUSINESS_NAME.LABEL') }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-n-ruby-9">*</span>
</label>
<Input
v-model="formValues.businessName"
:placeholder="
$t('BILLING_SETTINGS.CONFIRM_BUSINESS.BUSINESS_NAME.PLACEHOLDER')
"
:message="getErrorMessage('businessName')"
:message-type="v$.businessName.$error ? 'error' : 'info'"
@blur="v$.businessName.$touch"
/>
</div>
<div>
<label class="mb-1 text-heading-3 text-n-slate-12">
{{ $t('BILLING_SETTINGS.CONFIRM_BUSINESS.BUSINESS_ADDRESS.LABEL') }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-n-ruby-9">*</span>
</label>
<TextArea
v-model="formValues.businessAddress"
:placeholder="
$t('BILLING_SETTINGS.CONFIRM_BUSINESS.BUSINESS_ADDRESS.PLACEHOLDER')
"
:message="getErrorMessage('businessAddress')"
:message-type="v$.businessAddress.$error ? 'error' : 'info'"
:max-length="500"
min-height="5rem"
max-height="8rem"
@blur="v$.businessAddress.$touch"
/>
</div>
<div>
<label class="mb-1 text-heading-3 text-n-slate-12">
{{ $t('BILLING_SETTINGS.CONFIRM_BUSINESS.BILLING_EMAIL.LABEL') }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-n-ruby-9">*</span>
</label>
<Input
v-model="formValues.billingEmail"
type="email"
:placeholder="
$t('BILLING_SETTINGS.CONFIRM_BUSINESS.BILLING_EMAIL.PLACEHOLDER')
"
:message="getErrorMessage('billingEmail')"
:message-type="v$.billingEmail.$error ? 'error' : 'info'"
@blur="v$.billingEmail.$touch"
/>
<p v-if="!v$.billingEmail.$error" class="mt-1 text-xs text-n-slate-10">
{{ $t('BILLING_SETTINGS.CONFIRM_BUSINESS.BILLING_EMAIL.HELP_TEXT') }}
</p>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="$t('BILLING_SETTINGS.CONFIRM_BUSINESS.CANCEL')"
class="w-full"
:disabled="isLoading"
@click="close"
/>
<Button
color="blue"
:label="$t('BILLING_SETTINGS.CONFIRM_BUSINESS.CONTINUE')"
class="w-full"
:is-loading="isLoading"
@click="handleConfirm"
/>
</div>
</template>
</Dialog>
</template>
+8
View File
@@ -34,4 +34,12 @@ class AccountPolicy < ApplicationPolicy
def topup_checkout?
@account_user.administrator?
end
def billing_details?
@account_user.administrator?
end
def confirm_billing_details?
@account_user.administrator?
end
end
+2
View File
@@ -475,6 +475,8 @@ Rails.application.routes.draw do
post :checkout
post :subscription
get :limits
get :billing_details
post :confirm_billing_details
post :toggle_deletion
post :topup_checkout
end
@@ -4,6 +4,32 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
before_action :check_authorization
before_action :check_cloud_env, only: [:limits, :toggle_deletion]
def billing_details
return render json: { billing_details_confirmed: false }, status: :ok if stripe_customer_id.blank?
customer = Stripe::Customer.retrieve(stripe_customer_id)
render json: {
billing_details_confirmed: @account.custom_attributes['billing_details_confirmed'] || false,
business_name: customer.name,
billing_email: customer.email,
business_address: format_stripe_address(customer.address)
}, status: :ok
end
def confirm_billing_details
return render_invalid_billing_details if stripe_customer_id.blank?
Stripe::Customer.update(stripe_customer_id, {
name: params[:business_name],
email: params[:billing_email],
address: { line1: params[:business_address] }
})
@account.update!(custom_attributes: @account.custom_attributes.merge('billing_details_confirmed' => true))
create_stripe_billing_session(stripe_customer_id)
end
def subscription
if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank?
@account.update(custom_attributes: { is_creating_customer: true })
@@ -118,6 +144,12 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
end
end
def format_stripe_address(address)
return '' if address.blank?
[address.line1, address.line2, address.city, address.state, address.postal_code, address.country].compact_blank.join(', ')
end
def render_invalid_billing_details
render_could_not_create_error('Please subscribe to a plan before viewing the billing details')
end