Skip to content

Commit

Permalink
feat: add support for resuming soft-canceled subscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
kelsos committed Oct 8, 2024
1 parent 3f161b5 commit 86f8aa2
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 56 deletions.
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
Loading

0 comments on commit 86f8aa2

Please sign in to comment.