Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb2045e2e | ||
|
|
c147a82221 |
@@ -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>
|
||||
|
||||
+207
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user