Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds local state for rate-limit/ adds handling for validation error messages #261

Merged
merged 2 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 85 additions & 66 deletions components/account/home/AccountInformation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { get, objectOmit, set } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { useVatCheck } from '~/composables/use-vat-check';
import { useMainStore } from '~/store';
import { toMessages } from '~/utils/validation';
import { VatIdStatus } from '~/types/account';
Expand All @@ -18,44 +19,78 @@ const state = reactive({
vatId: '',
});

const loading = ref(false);
const done = ref(false);
const vatSuccessMessage = ref('');
const loading = ref<boolean>(false);
const done = ref<boolean>(false);
const vatSuccessMessage = ref<string>('');
const vatErrorMessage = ref<string>('');
const loadingCheck = ref<boolean>(false);
const $externalResults = ref<Record<string, string[]>>({});
const remainingTime = ref<number>(0);

const { account } = storeToRefs(store);

const movedOffline = computed(
() => get(account)?.address.movedOffline ?? false,
);

const isVatIdValid = computed(
() => get(account)?.vatIdStatus === VatIdStatus.VALID || false,
);

onBeforeMount(() => {
reset();
});

const rules = {
firstName: { required },
lastName: { required },
companyName: {},
vatId: {},
};

const $externalResults = ref<Record<string, string[]>>({});
const waitUntilTime = useLocalStorage<number>('rotki.vat_check.wait_until_time', 0);
kelsos marked this conversation as resolved.
Show resolved Hide resolved

const [DefineVAT, ReuseVAT] = createReusableTemplate();
const { refreshVATCheckStatus, checkVAT } = useVatCheck();

const v$ = useVuelidate(rules, state, {
$autoDirty: true,
$externalResults,
});

const { pause: pauseTimer, resume } = useIntervalFn(() => {
if (!updateRemainingTime()) {
set(remainingTime, 0);
pauseTimer();
}
}, 1000, { immediate: false });

const {
public: {
contact: { supportEmail, supportEmailMailto },
},
} = useRuntimeConfig();

const movedOffline = computed<boolean>(() => get(account)?.address.movedOffline ?? false);
const isVatIdValid = computed<boolean>(() => get(account)?.vatIdStatus === VatIdStatus.VALID || false);

const vatHint = computed<string>(() => {
const wait = get(remainingTime);
if (wait > 0) {
const formatted = formatSeconds(wait);
const time = `${(formatted.minutes || '00').toString().padStart(2, '0')}:${(formatted.seconds || '00').toString().padStart(2, '0')}`;
return t('auth.signup.vat.timer', { time });
}

return t('auth.signup.customer_information.form.vat_id_hint');
});

const hideVATVerifyButton = computed<boolean>(() => {
const status = get(account)?.vatIdStatus;
return status === VatIdStatus.NON_EU_ID;
});

const vatStatusErrorMessage = computed<string>(() => {
if (get(remainingTime) > 0 || state.vatId === '')
return '';
const status = get(account)?.vatIdStatus;
if (status === VatIdStatus.NOT_VALID) {
return t('auth.signup.vat.invalid');
}
else if (status === VatIdStatus.NOT_CHECKED) {
return t('auth.signup.vat.not_verified');
}
return '';
});

function reset() {
const userAccount = get(account);

Expand Down Expand Up @@ -103,69 +138,53 @@ async function update() {
set(loading, false);
}

const waitTime = ref<number>(0);
const loadingCheck = ref<boolean>(false);

const { pause: pauseTimer, resume } = useIntervalFn(() => {
const wait = get(waitTime);
if (wait > 0) {
set(waitTime, wait - 1);
}
else {
pauseTimer();
}
}, 1000, { immediate: false });

async function handleCheckVATClick() {
set(loadingCheck, true);
const result = await store.checkVAT();
await store.refreshVATCheckStatus();
const checkResult = await checkVAT();
await refreshVATCheckStatus();

if (typeof result === 'number') {
set(waitTime, result);
if ('seconds' in checkResult) {
const now = Math.round(Date.now() / 1000);
set(waitUntilTime, now + checkResult.seconds);
set(remainingTime, checkResult.seconds);
pauseTimer();
if (result > 0) {
if (checkResult.seconds > 0) {
resume();
}
}
else if (result) {
set(vatSuccessMessage, t('auth.signup.vat.verified'));
else if (checkResult.success) {
const status = get(account)?.vatIdStatus;
if (status === VatIdStatus.VALID) {
set(vatSuccessMessage, t('auth.signup.vat.verified'));
setTimeout(() => {
set(vatSuccessMessage, '');
}, 3000);
}
}
else {
set(vatErrorMessage, checkResult.message);
setTimeout(() => {
set(vatSuccessMessage, '');
set(vatErrorMessage, '');
}, 3000);
}
set(loadingCheck, false);
}

const [DefineVAT, ReuseVAT] = createReusableTemplate();
function updateRemainingTime(): boolean {
const now = Math.round(Date.now() / 1000);
const endTime = get(waitUntilTime);

const vatHint = computed(() => {
const wait = get(waitTime);
if (wait > 0) {
const formatted = formatSeconds(wait);
const time = `${(formatted.minutes || '00').toString().padStart(2, '0')}:${(formatted.seconds || '00').toString().padStart(2, '0')}`;
return t('auth.signup.vat.timer', { time });
if (endTime > now) {
set(remainingTime, endTime - now);
return true;
}
return false;
}

return t('auth.signup.customer_information.form.vat_id_hint');
});

const hideVATVerifyButton = computed(() => {
const status = get(account)?.vatIdStatus;
return status === VatIdStatus.NON_EU_ID;
});

const vatErrorMessage = computed(() => {
if (get(waitTime) > 0)
return '';
const status = get(account)?.vatIdStatus;
if (status === VatIdStatus.NOT_VALID) {
return t('auth.signup.vat.invalid');
}
else if (status === VatIdStatus.NOT_CHECKED) {
return t('auth.signup.vat.not_verified');
}
return '';
onMounted(() => {
reset();
resume();
updateRemainingTime();
});
</script>

Expand All @@ -181,13 +200,13 @@ const vatErrorMessage = computed(() => {
class="flex-1"
:label="t('auth.signup.customer_information.form.vat_id')"
:hint="vatHint"
:error-messages="[...toMessages(v$.vatId), vatErrorMessage]"
:error-messages="[...toMessages(v$.vatId), vatStatusErrorMessage, vatErrorMessage]"
:success-messages="vatSuccessMessage"
@blur="v$.vatId.$touch()"
>
<template #append>
<RuiIcon
v-if="!hideVATVerifyButton"
v-if="!hideVATVerifyButton && state.vatId"
size="16"
:name="isVatIdValid ? 'lu-circle-check' : 'lu-circle-x'"
:color="isVatIdValid ? 'success' : 'error'"
Expand All @@ -198,7 +217,7 @@ const vatErrorMessage = computed(() => {
v-if="!hideVATVerifyButton"
color="primary"
class="h-10"
:disabled="waitTime > 0"
:disabled="remainingTime > 0 || !state.vatId"
:loading="loadingCheck"
@click="handleCheckVATClick()"
>
Expand Down
106 changes: 106 additions & 0 deletions composables/use-vat-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { get, set } from '@vueuse/core';
import { FetchError } from 'ofetch';
import { useMainStore } from '~/store';
import { fetchWithCsrf } from '~/utils/api';
import type { ApiResponse } from '~/types';

interface VATCheckSuccess {
readonly success: true;
}

interface VATCheckFailure {
readonly success: false;
readonly message: string;
}

type VATCheckResult = VATCheckSuccess | VATCheckFailure;

interface VATCheckRateLimited {
readonly seconds: number;
}

interface UseVATCheckReturn {
checkVAT: () => Promise<VATCheckResult | VATCheckRateLimited>;
refreshVATCheckStatus: () => Promise<void>;
}

export function useVatCheck(): UseVATCheckReturn {
const { account } = storeToRefs(useMainStore());

const refreshVATCheckStatus = async (): Promise<void> => {
try {
const response = await fetchWithCsrf<ApiResponse<string>>(
'webapi/account/vat',
{
method: 'GET',
},
);
const { result } = response;
if (result) {
set(account, {
...get(account),
vatIdStatus: result,
});
}
}
catch (error) {
logger.error(error);
}
};

/**
* Asynchronous function to check the validity of a VAT (Value Added Tax) ID.
*
* @async
* @function
* @returns {Promise<VATCheckResult | VATCheckRateLimited>} Returns a promise that resolves to either a VATCheckResult object
* containing the validation result and message, or a VATCheckRateLimited object indicating the rate limit status with remaining time.
*
* Possible return values:
* - An object with `result: true` if the VAT ID check task was spawned successfully.
* It provides no information about the validity of the VAT ID itself.
* A check to the account's vat id status property is required.
* - An object with `result: false` and a message if the VAT validation fails.
* - A VATCheckRateLimited object with `seconds` indicating the number of seconds until the rate limit is lifted, in case of rate-limiting.
* - A response with `message: 'Unknown error'` in case of unspecified errors.
*
* Handles potential errors during the fetch operation and logs them.
*/
const checkVAT = async (): Promise<VATCheckResult | VATCheckRateLimited> => {
try {
const response = await fetchWithCsrf<ApiResponse<boolean>>(
'webapi/account/vat',
{
method: 'POST',
},
);
if (response.result) {
return { success: true };
}

return { message: response.message, success: false };
}
catch (error) {
logger.error(error);
if (error instanceof FetchError) {
const status = error?.status || -1;
const result = error.data.result;
const isBadRequest = status === 400;
if (isBadRequest) {
if (typeof result === 'number') {
return { seconds: result };
}
else if (error.data.message) {
return { message: error.data.message, success: false };
}
}
}
return { message: 'Unknown error', success: false };
}
};

return {
checkVAT,
refreshVATCheckStatus,
};
}
Loading
Loading