feat: verify billing details before checkout

This commit is contained in:
Muhsin
2026-03-28 16:09:30 +04:00
parent 9efd554693
commit c147a82221
7 changed files with 307 additions and 1 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": {
@@ -12,6 +12,7 @@ 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';
@@ -35,11 +36,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}
@@ -119,7 +125,15 @@ const handleBillingPageLogic = async () => {
};
const onClickBillingPortal = () => {
store.dispatch('accounts/checkout');
if (billingDetailsConfirmed.value) {
store.dispatch('accounts/checkout');
} else {
confirmBusinessDetailsModalRef.value?.open();
}
};
const onBusinessDetailsConfirmed = redirectUrl => {
window.location = redirectUrl;
};
const onToggleChatWindow = () => {
@@ -263,6 +277,10 @@ onMounted(handleBillingPageLogic);
ref="purchaseCreditsModalRef"
@success="handleTopupSuccess"
/>
<ConfirmBusinessDetailsModal
ref="confirmBusinessDetailsModalRef"
@confirmed="onBusinessDetailsConfirmed"
/>
</template>
</SettingsLayout>
</template>
@@ -0,0 +1,209 @@
<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 emit = defineEmits(['confirmed']);
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const isFetchingDetails = ref(false);
const hasPreExistingData = ref(false);
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 fetchBillingDetails = async () => {
isFetchingDetails.value = true;
try {
const { data } = await EnterpriseAccountAPI.getBillingDetails();
if (data.business_name || data.business_address || data.billing_email) {
formValues.businessName = data.business_name || '';
formValues.businessAddress = data.business_address || '';
formValues.billingEmail = data.billing_email || '';
hasPreExistingData.value = true;
}
} catch {
// Silently fail - user can still fill in details manually
} finally {
isFetchingDetails.value = false;
}
};
const open = () => {
v$.value.$reset();
formValues.businessName = '';
formValues.businessAddress = '';
formValues.billingEmail = '';
hasPreExistingData.value = false;
dialogRef.value?.open();
fetchBillingDetails();
};
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-2 p-3 rounded-lg bg-n-teal-2 border border-n-teal-6"
>
<fluent-icon icon="checkmark-circle" size="16" class="text-n-teal-11" />
<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>