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

Enable subscription resume functionality #248

Merged
merged 6 commits into from
Oct 8, 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
131 changes: 131 additions & 0 deletions components/account/home/ResumeSubscriptionDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { get, set } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { useMainStore } from '~/store';
import type { Subscription } from '~/types';

const modelValue = defineModel<Subscription | undefined>({ required: true });

const emit = defineEmits<{
confirm: [val: Subscription];
}>();

const { t } = useI18n();

const { resumeError } = storeToRefs(useMainStore());

const display = computed({
get() {
return !!get(modelValue);
},
set(value) {
if (!value)
set(modelValue, undefined);
},
});

async function resumeSubscription() {
if (!isDefined(modelValue))
return;
emit('confirm', get(modelValue));
set(modelValue, undefined);
}
</script>

<template>
<RuiDialog
v-model="display"
max-width="900"
>
<RuiCard>
<template #header>
{{ t('account.subscriptions.resume.title') }}
</template>

<div class="whitespace-break-spaces mb-4">
<div v-if="!!modelValue">
<ul class="list-disc ml-5 font-medium">
<li>
<i18n-t
keypath="account.subscriptions.resume.details.plan_name"
class="font-medium"
>
<template #plan>
<span class="font-normal">{{ modelValue.planName }}</span>
</template>
</i18n-t>
</li>
<li>
<i18n-t
keypath="account.subscriptions.resume.details.billing_cycle"
class="font-medium"
>
<template #duration>
<span class="font-normal">
{{ t('account.subscriptions.resume.details.duration_in_months', { duration: modelValue.durationInMonths }) }}
</span>
</template>
</i18n-t>
</li>

<li>
<i18n-t
keypath="account.subscriptions.resume.details.billing_amount"
class="font-medium"
>
<template #amount>
<span class="font-normal">
{{ t('account.subscriptions.resume.details.amount_in_eur', { amount: modelValue.nextBillingAmount }) }}
</span>
</template>
</i18n-t>
</li>

<li>
<i18n-t
keypath="account.subscriptions.resume.details.next_billing_date"
class="font-medium"
>
<template #date>
<span class="font-normal">{{ modelValue.nextActionDate }}</span>
</template>
</i18n-t>
</li>
</ul>
</div>
<div class="mt-4">
{{ t('account.subscriptions.resume.description') }}
</div>
</div>

<div class="flex justify-end gap-4 pt-4">
<RuiButton
color="primary"
variant="text"
@click="modelValue = undefined"
>
{{ t('account.subscriptions.resume.actions.no') }}
</RuiButton>

<RuiButton
color="info"
@click="resumeSubscription()"
>
{{ t('account.subscriptions.resume.actions.yes') }}
</RuiButton>
</div>
</RuiCard>
</RuiDialog>

<FloatingNotification
:timeout="3000"
:visible="!!resumeError"
closeable
@dismiss="resumeError = ''"
>
<template #title>
{{ t('account.subscriptions.resume.notification.title') }}
</template>
{{ resumeError }}
</FloatingNotification>
</template>
84 changes: 57 additions & 27 deletions components/account/home/SubscriptionTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@ import type {
} from '@rotki/ui-library';
import type { Subscription } from '~/types';

const pagination = ref<TablePaginationData>();
const sort = ref<DataTableSortColumn<Subscription>[]>([]);
const selectedSubscription = ref<Subscription>();
const showCancelDialog = ref<boolean>(false);
const cancelling = ref<boolean>(false);
const resuming = ref<boolean>(false);
const resumingSubscription = ref<Subscription>();

const { t } = useI18n();

const store = useMainStore();
const { subscriptions } = storeToRefs(store);

const { cancelUserSubscription, resumeUserSubscription } = useSubscription();
const { pause, resume, isActive } = useIntervalFn(
async () => await store.getAccount(),
60000,
);

const headers: DataTableColumn<Subscription>[] = [
{
label: t('common.plan'),
Expand Down Expand Up @@ -47,15 +64,6 @@ const headers: DataTableColumn<Subscription>[] = [
},
];

const store = useMainStore();
const { subscriptions } = storeToRefs(store);

const pagination = ref<TablePaginationData>();
const sort = ref<DataTableSortColumn<Subscription>[]>([]);
const selectedSubscription = ref<Subscription>();
const showCancelDialog = ref<boolean>(false);
const cancelling = ref<boolean>(false);

const pending = computed(() => get(subscriptions).filter(sub => sub.pending));

const renewableSubscriptions = computed(() =>
Expand Down Expand Up @@ -101,27 +109,18 @@ const renewLink = computed<{ path: string; query: Record<string, string> }>(() =
}

return link;
},
);

const { pause, resume, isActive } = useIntervalFn(
async () => await store.getAccount(),
60000,
);

watch(pending, (pending) => {
if (pending.length === 0)
pause();
else if (!get(isActive))
resume();
});

onUnmounted(() => pause());

function isPending(sub: Subscription) {
return sub.status === 'Pending';
}

async function resumeSubscription(sub: Subscription): Promise<void> {
set(resuming, true);
await resumeUserSubscription(sub.identifier);
set(resuming, false);
}

function hasAction(sub: Subscription, action: 'renew' | 'cancel') {
if (action === 'cancel')
return sub.status !== 'Pending' && sub.actions.includes('cancel');
Expand All @@ -132,7 +131,10 @@ function hasAction(sub: Subscription, action: 'renew' | 'cancel') {
}

function displayActions(sub: Subscription) {
return hasAction(sub, 'renew') || hasAction(sub, 'cancel') || isPending(sub);
return hasAction(sub, 'renew')
|| hasAction(sub, 'cancel')
|| isPending(sub)
|| sub.isSoftCanceled;
}

function getChipStatusColor(status: string): ContextColorsType | undefined {
Expand All @@ -155,10 +157,19 @@ function confirmCancel(sub: Subscription) {
async function cancelSubscription(sub: Subscription) {
set(showCancelDialog, false);
set(cancelling, true);
await store.cancelSubscription(sub);
await cancelUserSubscription(sub);
set(cancelling, false);
set(selectedSubscription, undefined);
}

watch(pending, (pending) => {
if (pending.length === 0)
pause();
else if (!get(isActive))
resume();
});

onUnmounted(() => pause());
</script>

<template>
Expand Down Expand Up @@ -232,10 +243,24 @@ async function cancelSubscription(sub: Subscription) {
>
{{ t('account.subscriptions.payment_detail') }}
</ButtonLink>
<RuiTooltip v-if="row.isSoftCanceled">
<template #activator>
<RuiButton
:loading="resuming"
variant="text"
type="button"
color="info"
@click="resumingSubscription = row"
>
{{ t('actions.resume') }}
</RuiButton>
</template>
{{ t('account.subscriptions.resume_hint', { date: row.nextActionDate }) }}
</RuiTooltip>
</div>
<div
v-else
class="capitalize"
class="capitalize mx-4 my-2"
>
{{ t('common.none') }}
</div>
Expand All @@ -247,5 +272,10 @@ async function cancelSubscription(sub: Subscription) {
:subscription="selectedSubscription"
@cancel="cancelSubscription($event)"
/>

<ResumeSubscriptionDialog
v-model="resumingSubscription"
@confirm="resumeSubscription($event)"
/>
</div>
</template>
72 changes: 72 additions & 0 deletions composables/use-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { get, set } from '@vueuse/core';
import { FetchError } from 'ofetch';
import { useMainStore } from '~/store';
import { ActionResultResponse, type Subscription } from '~/types';
import { fetchWithCsrf } from '~/utils/api';
import { assert } from '~/utils/assert';

interface UseSubscriptionReturn {
cancelUserSubscription: (subscription: Subscription) => Promise<void>;
resumeUserSubscription: (identifier: string) => Promise<void>;
}

export function useSubscription(): UseSubscriptionReturn {
const store = useMainStore();
const { account, cancellationError, resumeError } = storeToRefs(store);
const { getAccount } = store;

const resumeUserSubscription = async (identifier: string) => {
const acc = get(account);
assert(acc);

try {
const response = await fetchWithCsrf<ActionResultResponse>(
`/webapi/subscription/${identifier}/resume/`,
{
method: 'PATCH',
},
);
const data = ActionResultResponse.parse(response);
if (data.result)
await getAccount();
}
catch (error: any) {
let message = error.message;
if (error instanceof FetchError && error.status === 404)
message = ActionResultResponse.parse(error.data).message;

logger.error(error);
set(resumeError, message);
}
};

const cancelUserSubscription = async (subscription: Subscription): Promise<void> => {
const acc = get(account);
assert(acc);

try {
const response = await fetchWithCsrf<ActionResultResponse>(
`/webapi/subscription/${subscription.identifier}/`,
{
method: 'DELETE',
},
);
const data = ActionResultResponse.parse(response);
if (data.result)
await getAccount();
}
catch (error: any) {
let message = error.message;
if (error instanceof FetchError && error.status === 404)
message = ActionResultResponse.parse(error.data).message;

logger.error(error);
set(cancellationError, message);
}
};

return {
cancelUserSubscription,
resumeUserSubscription,
};
}
20 changes: 19 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,24 @@
},
"no_subscriptions_found": "No subscriptions found",
"payment_detail": "Payment Details",
"title": "Subscription History"
"title": "Subscription History",
"resume": {
"title": "Resume your rotki subscription",
"actions": {
"yes": "Yes, resume my subscription",
"no": "No, don't resume"
},
"description": "By confirming, your subscription's billing cycle will resume, and you will be charged according to your selected plan starting from the next billing date. Are you sure you want to resume your subscription?",
"details": {
"plan_name": "Subscription Plan: {plan}",
"billing_cycle": "Billing Cycle: {duration}",
"billing_amount": "Billing Amount: {amount}",
"amount_in_eur": "{amount} €",
"duration_in_months": "every {duration} month|every {duration} months",
"next_billing_date": "Next billing date: {date}"
}
},
"resume_hint": "Resume the active billing of your subscription from {date}"
},
"tabs": {
"subscription": "Subscription",
Expand All @@ -248,6 +265,7 @@
"regenerate": "Regenerate",
"renew": "Renew",
"reset": "Reset",
"resume": "Resume",
"start_now_for_free": "Start now for free",
"submit": "Submit",
"update": "Update",
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export default defineNuxtConfig({
css: [],

devtools: {
enabled: !!process.env.CI || !!process.env.TEST,
enabled: process.env.NODE_ENV === 'development' && !(!!process.env.CI || !!process.env.TEST),
},

i18n: {
Expand Down
Loading
Loading