feat(subscriptions): refactor subscription handling and update related queries

- Renamed `hasUserTrialSubscription` to `usedTrialSubscription` for clarity in the SubscriptionsService.
- Updated subscription-related queries and fragments to use `active` instead of `isActive` for consistency.
- Enhanced the ProPage component to utilize the new subscription checks and improve trial usage logic.
- Removed unused subscription history query to streamline the codebase.
- Adjusted the SubscriptionInfoBar to reflect the new subscription state handling.
This commit is contained in:
vchikalkin 2025-09-16 18:37:35 +03:00
parent eab6da5e89
commit a6d05bcf69
10 changed files with 213 additions and 266 deletions

View File

@ -18,11 +18,11 @@ export async function subscription(conversation: Conversation<Context, Context>,
const subscriptionsService = new SubscriptionsService({ telegramId });
const hasUserTrial = await subscriptionsService.hasUserTrialSubscription();
const hasUserTrial = await subscriptionsService.usedTrialSubscription();
const { subscriptionPrices } = await subscriptionsService.getSubscriptionPrices({
filters: {
isActive: {
active: {
eq: true,
},
period: {

View File

@ -4,25 +4,16 @@ import { PageHeader } from '@/components/navigation';
import { TryFreeButton } from '@/components/subscription';
import { env } from '@/config/env';
import { Button } from '@repo/ui/components/ui/button';
import { ArrowRight, Crown, Infinity as InfinityIcon, Star, Users } from 'lucide-react';
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
import Link from 'next/link';
export default async function ProPage() {
const { telegramId } = await getSessionUser();
const subscriptionData = await getSubscription({ telegramId });
const { hasActiveSubscription, usedTrialSubscription } = await getSubscription({
telegramId,
});
// Простая логика для проверки статуса подписки
const subscription = subscriptionData?.subscription;
const isActive =
subscription?.isActive &&
subscription?.expiresAt &&
new Date() < new Date(subscription.expiresAt);
// Проверка возможности использования пробного периода
const hasUsedTrial = subscription?.subscriptionHistories?.some(
(item) => item && item.period === 'trial' && item.state === 'success',
);
const canUseTrial = !isActive && !hasUsedTrial;
const canUseTrial = !usedTrialSubscription;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
@ -47,10 +38,12 @@ export default async function ProPage() {
</h1>
<p className="mx-auto mb-8 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
{isActive ? 'Ваша подписка Pro активна!' : 'Разблокируйте больше возможностей'}
{hasActiveSubscription
? 'Ваша подписка Pro активна!'
: 'Разблокируйте больше возможностей'}
</p>
{!isActive && (
{!hasActiveSubscription && (
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
{canUseTrial && <TryFreeButton />}
@ -86,23 +79,14 @@ export default async function ProPage() {
</p>
</div>
<div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
<div className="mt-1 shrink-0">
<Users className="size-5 text-purple-600 dark:text-purple-400" />
</div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Ваш профиль доступен всем пользователям в поиске
</p>
</div>
<div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
{/* <div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
<div className="mt-1 shrink-0">
<Star className="size-5 text-purple-600 dark:text-purple-400" />
</div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Профиль и аватар выделяются цветом
</p>
</div>
</div> */}
</div>
</div>
</div>

View File

@ -5,7 +5,6 @@ import { useCustomerQuery } from '@/hooks/api/customers';
import { useSubscriptionQuery } from '@/hooks/api/subscriptions';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
import { getRemainingDays } from '@repo/utils/datetime-format';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
@ -14,10 +13,10 @@ export function SubscriptionInfoBar() {
const { data: { customer } = {} } = useCustomerQuery();
const isActive = data?.subscription?.isActive;
const isActive = data?.hasActiveSubscription;
const remainingOrdersCount = data?.remainingOrdersCount;
const remainingDays = data?.remainingDays;
const maxOrdersPerMonth = data?.maxOrdersPerMonth;
const expiresAt = data?.subscription?.expiresAt;
if (customer?.role === Enum_Customer_Role.Client) return null;
@ -31,8 +30,8 @@ export function SubscriptionInfoBar() {
description = `Доступно ${remainingOrdersCount} из ${maxOrdersPerMonth} записей в этом месяце`;
}
if (isActive && expiresAt) {
description = `Осталось ${getRemainingDays(expiresAt)} дней`;
if (isActive) {
description = `Осталось ${remainingDays} дней`;
}
return (

View File

@ -6,7 +6,6 @@ import {
createSubscriptionHistory,
createTrialSubscription,
getSubscription,
getSubscriptionHistory,
getSubscriptionPrices,
getSubscriptionSettings,
updateSubscription,
@ -43,16 +42,6 @@ export const useSubscriptionPricesQuery = (
});
};
export const useSubscriptionHistoryQuery = (
variables: Parameters<typeof getSubscriptionHistory>[0],
) => {
return useQuery({
enabled: Boolean(variables.subscriptionId),
queryFn: () => getSubscriptionHistory(variables),
queryKey: ['subscriptionHistory', variables.subscriptionId],
});
};
export const useSubscriptionMutation = () => {
const { data: session } = useSession();
const telegramId = session?.user?.telegramId;

View File

@ -53,6 +53,8 @@ export class BaseService {
const customer = result.data.customers.at(0);
if (!customer) throw new Error(ERRORS.NOT_FOUND_CUSTOMER);
if (customer && isCustomerBanned(customer)) {
throw new Error(SHARED_ERRORS.NO_PERMISSION);
}

View File

@ -74,7 +74,7 @@ export class OrdersService extends BaseService {
const isMasterCreating = slot.master.documentId === customer?.documentId;
// Если у мастера слота нет активной подписки и не осталось доступных заказов
if (!subscription?.isActive && remainingOrdersCount <= 0) {
if (!subscription?.active && remainingOrdersCount <= 0) {
throw new Error(
isMasterCreating ? ERRORS.ORDER_LIMIT_EXCEEDED_MASTER : ERRORS.ORDER_LIMIT_EXCEEDED_CLIENT,
);

View File

@ -4,6 +4,11 @@ import { BaseService } from './base';
import { OrdersService } from './orders';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
if (!dayjs.prototype.minMax) {
dayjs.extend(minMax);
}
export const ERRORS = {
FAILED_TO_CREATE_TRIAL_SUBSCRIPTION: 'Не удалось создать пробную подписку',
@ -16,7 +21,6 @@ export const ERRORS = {
export class SubscriptionsService extends BaseService {
async createOrUpdateSubscription(payload: { period: GQL.Enum_Subscriptionprice_Period }) {
// ищем цену по выбранному периоду
const { subscriptionPrices } = await this.getSubscriptionPrices({
filters: { period: { eq: payload.period } },
});
@ -24,75 +28,50 @@ export class SubscriptionsService extends BaseService {
const subscriptionPrice = subscriptionPrices[0];
if (!subscriptionPrice) throw new Error('Subscription price not found');
// получаем текущую подписку
const { subscription: existingSubscription } = await this.getSubscription({
telegramId: this._user.telegramId,
});
let expiresAt: string;
const newExpiresAt = dayjs
.max(dayjs(existingSubscription?.expiresAt), dayjs())
.add(subscriptionPrice.days, 'day');
if (existingSubscription?.expiresAt) {
// --- продлеваем подписку ---
expiresAt = dayjs(existingSubscription.expiresAt)
.add(subscriptionPrice.days, 'day')
.toISOString();
const result = await this.updateSubscription({
data: { expiresAt },
documentId: existingSubscription.documentId,
});
// создаём запись в истории
await this.createSubscriptionHistory({
data: {
amount: subscriptionPrice.amount,
currency: 'RUB',
description: subscriptionPrice.description ?? 'Продление подписки',
endDate: expiresAt,
period: subscriptionPrice.period,
source: GQL.Enum_Subscriptionhistory_Source.Renewal,
startDate: existingSubscription.expiresAt,
state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: result?.updateSubscription?.documentId,
},
});
} else {
// --- создаём новую подписку ---
const { customer } = await this.checkIsBanned();
if (!customer?.documentId) throw new Error('Customer not found');
const expiresAtNew = dayjs().add(subscriptionPrice.days, 'day').toISOString();
const result = await this.createSubscription({
data: {
autoRenew: true,
active: true,
customer: customer.documentId,
expiresAt: expiresAtNew,
isActive: true,
expiresAt: newExpiresAt.toISOString(),
},
});
expiresAt = result?.createSubscription?.expiresAt ?? expiresAtNew;
// Добавляем в последнюю подписку ссылку на новую только что созданную
if (result?.createSubscription && existingSubscription)
await this.updateSubscription({
data: {
active: true,
nextSubscription: result.createSubscription.documentId,
},
documentId: existingSubscription?.documentId,
});
// создаём запись в истории
await this.createSubscriptionHistory({
data: {
amount: subscriptionPrice.amount,
currency: 'RUB',
description: subscriptionPrice.description ?? 'Новая подписка',
endDate: expiresAt,
description: existingSubscription ? 'Продление подписки' : 'Новая подписка',
period: subscriptionPrice.period,
source: GQL.Enum_Subscriptionhistory_Source.Payment,
startDate: dayjs().toISOString(),
state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: result?.createSubscription?.documentId,
subscription_price: subscriptionPrice.documentId,
},
});
}
return {
expiresAt,
formattedDate: dayjs(expiresAt).toDate().toLocaleDateString('ru-RU', {
expiresAt: newExpiresAt.toDate(),
formattedDate: newExpiresAt.toDate().toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@ -139,13 +118,13 @@ export class SubscriptionsService extends BaseService {
const { customer } = await this.checkIsBanned();
// Проверяем, не использовал ли пользователь уже пробный период
const hasUserTrial = await this.hasUserTrialSubscription();
const hasUserTrial = await this.usedTrialSubscription();
if (hasUserTrial) throw new Error(ERRORS.TRIAL_PERIOD_ALREADY_USED);
// Получаем цены подписки для определения длительности пробного периода
const { subscriptionPrices } = await this.getSubscriptionPrices({
filters: {
isActive: {
active: {
eq: true,
},
},
@ -157,7 +136,7 @@ export class SubscriptionsService extends BaseService {
(price) => price?.period === GQL.Enum_Subscriptionprice_Period.Trial,
);
if (!trialPrice) throw new Error(ERRORS.TRIAL_PERIOD_NOT_FOUND);
if (!trialPrice.isActive) throw new Error(ERRORS.TRIAL_PERIOD_NOT_ACTIVE);
if (!trialPrice.active) throw new Error(ERRORS.TRIAL_PERIOD_NOT_ACTIVE);
const trialPeriodDays = trialPrice?.days;
const now = dayjs();
@ -166,10 +145,9 @@ export class SubscriptionsService extends BaseService {
// Создаем пробную подписку
const subscriptionData = await this.createSubscription({
data: {
autoRenew: false,
active: true,
customer: customer?.documentId,
expiresAt: expiresAt.toISOString(),
isActive: true,
},
});
@ -185,10 +163,8 @@ export class SubscriptionsService extends BaseService {
amount: 0,
currency: 'RUB',
description: `Пробный период на ${trialPeriodDays} дней`,
endDate: expiresAt.toISOString(),
period: GQL.Enum_Subscriptionhistory_Period.Trial,
period: trialPrice.period,
source: GQL.Enum_Subscriptionhistory_Source.Trial,
startDate: now.toISOString(),
state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: subscription.documentId,
},
@ -197,32 +173,41 @@ export class SubscriptionsService extends BaseService {
return subscriptionData;
}
async getSubscription(variables: VariablesOf<typeof GQL.GetSubscriptionDocument>) {
async getSubscription({
telegramId,
}: Pick<VariablesOf<typeof GQL.GetCustomerDocument>, 'telegramId'>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSubscriptionDocument,
variables,
const data = await this.getSubscriptions({
filters: {
active: {
eq: true,
},
customer: {
telegramId: { eq: telegramId },
},
},
});
const subscription = result.data.subscriptions.at(0);
const hasActiveSubscription = Boolean(
subscription?.isActive &&
subscription?.expiresAt &&
new Date() < new Date(subscription.expiresAt),
);
const subscription = data.subscriptions.find((x) => !x?.nextSubscription?.documentId);
const remainingDays = subscription ? this.getRemainingDays(subscription) : 0;
const hasActiveSubscription = subscription?.active && remainingDays > 0;
const { maxOrdersPerMonth, remainingOrdersCount } = await this.getRemainingOrdersCount();
return { hasActiveSubscription, maxOrdersPerMonth, remainingOrdersCount, subscription };
const usedTrialSubscription = await this.usedTrialSubscription();
return {
hasActiveSubscription,
maxOrdersPerMonth,
remainingDays,
remainingOrdersCount,
subscription,
usedTrialSubscription,
};
}
async getSubscriptionHistory(variables: VariablesOf<typeof GQL.GetSubscriptionHistoryDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
@ -248,6 +233,19 @@ export class SubscriptionsService extends BaseService {
return result.data;
}
async getSubscriptions(variables?: VariablesOf<typeof GQL.GetSubscriptionsDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSubscriptionsDocument,
variables,
});
return result.data;
}
async getSubscriptionSettings() {
await this.checkIsBanned();
@ -260,27 +258,6 @@ export class SubscriptionsService extends BaseService {
return result.data;
}
async hasUserTrialSubscription() {
const { customer } = await this.checkIsBanned();
const { subscription: existingSubscription } = await this.getSubscription({
telegramId: customer?.telegramId,
});
if (!existingSubscription) return false;
const { subscriptionHistories } = await this.getSubscriptionHistory({
filters: {
period: { eq: GQL.Enum_Subscriptionhistory_Period.Trial },
subscription: { documentId: { eq: existingSubscription.documentId } },
},
});
return subscriptionHistories?.some(
(history) => history?.state === GQL.Enum_Subscriptionhistory_State.Success,
);
}
async updateSubscription(variables: VariablesOf<typeof GQL.UpdateSubscriptionDocument>) {
await this.checkIsBanned();
@ -315,6 +292,49 @@ export class SubscriptionsService extends BaseService {
return mutationResult.data;
}
async usedTrialSubscription() {
const { customer } = await this._getUser();
const { subscriptionHistories } = await this.getSubscriptionHistory({
filters: {
or: [
{
source: {
eq: GQL.Enum_Subscriptionhistory_Source.Trial,
},
},
{
period: {
eq: GQL.Enum_Subscriptionprice_Period.Trial,
},
},
],
subscription: {
customer: {
documentId: {
eq: customer?.documentId,
},
},
},
},
});
return subscriptionHistories?.some(
(history) => history?.state === GQL.Enum_Subscriptionhistory_State.Success,
);
}
private getRemainingDays(subscription: GQL.SubscriptionFieldsFragment) {
if (!subscription) return 0;
const remainingDays = dayjs(subscription?.expiresAt).diff(dayjs(), 'day', true);
if (remainingDays <= 0) return 0;
return Math.ceil(remainingDays);
}
private async getRemainingOrdersCount() {
const ordersService = new OrdersService(this._user);

View File

@ -1,21 +1,24 @@
fragment SubscriptionFields on Subscription {
documentId
isActive
active
expiresAt
autoRenew
nextSubscription {
documentId
}
}
fragment SubscriptionHistoryFields on SubscriptionHistory {
documentId
period
startDate
endDate
amount
currency
state
paymentId
source
description
subscription {
...SubscriptionFields
}
}
fragment SubscriptionSettingFields on SubscriptionSetting {
@ -30,7 +33,7 @@ fragment SubscriptionPriceFields on SubscriptionPrice {
days
amount
currency
isActive
active
description
}
@ -48,8 +51,8 @@ fragment SubscriptionRewardFields on SubscriptionReward {
}
}
query GetSubscription($telegramId: Long) {
subscriptions(filters: { customer: { telegramId: { eq: $telegramId } } }) {
query GetSubscriptions($filters: SubscriptionFiltersInput) {
subscriptions(filters: $filters) {
...SubscriptionFields
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,5 @@
/* eslint-disable import/no-unassigned-import */
import { type OpUnitType } from 'dayjs';
import dayjs, { type ConfigType } from 'dayjs';
import dayjs, { type ConfigType, type OpUnitType } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import 'dayjs/locale/ru';
@ -85,10 +84,6 @@ export function getMinutes(time: string) {
return Number.parseInt(hours, 10) * 60 + Number.parseInt(minutes, 10);
}
export function getRemainingDays(date: DateTime) {
return dayjs(date).diff(dayjs(), 'day');
}
export function getTimeZoneLabel(tz: string = DEFAULT_TZ): string {
if (tz === DEFAULT_TZ) return 'МСК';
const offset = dayjs().tz(tz).format('Z');