test payment

This commit is contained in:
vchikalkin 2025-09-12 13:20:20 +03:00
parent 6228832aff
commit eab6da5e89
16 changed files with 343 additions and 75 deletions

View File

@ -30,12 +30,14 @@ sharebot =
.description = Поделиться ботом .description = Поделиться ботом
help = help =
.description = Список команд и поддержка .description = Список команд и поддержка
subscribe =
.description = Оформить подписку
commands-list = commands-list =
📋 Доступные команды: 📋 Доступные команды:
• /addcontact — добавить контакт клиента • /addcontact — добавить контакт клиента
• /becomemaster — стать мастером
• /sharebot — поделиться ботом • /sharebot — поделиться ботом
• /subscribe — оформить подписку
• /help — список команд • /help — список команд
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
@ -75,9 +77,7 @@ msg-already-registered =
msg-invalid-phone = ❌ Некорректный номер телефона msg-invalid-phone = ❌ Некорректный номер телефона
# Сообщения о контактах # Сообщения о контактах
msg-send-client-contact = msg-send-client-contact = 👤 Отправьте контакт клиента, которого вы хотите добавить.
👤 Отправьте контакт клиента, которого вы хотите добавить.
Для отмены операции используйте команду /cancel
msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram msg-send-contact = Пожалуйста, отправьте контакт клиента через кнопку Telegram
@ -102,4 +102,19 @@ err-generic = ⚠️ Что-то пошло не так. Попробуйте е
err-banned = 🚫 Ваш аккаунт заблокирован err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка err-with-details = ❌ Произошла ошибка
{ $error } { $error }
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
msg-cancel-operation = Для отмены операции используйте команду /cancel
# Сообщения о подписке
msg-subscribe =
👑 Подписка Pro:
• Разблокирует неограниченное количество заказов
msg-subscribe-success =
✅ Платеж успешно обработан!
msg-subscribe-error =
❌ Произошла ошибка при обработке платежа
msg-subscribe-active-until = 📅 Ваша подписка активна до { $date }

View File

@ -25,10 +25,12 @@
"@repo/graphql": "workspace:*", "@repo/graphql": "workspace:*",
"@repo/typescript-config": "workspace:*", "@repo/typescript-config": "workspace:*",
"@types/node": "catalog:", "@types/node": "catalog:",
"dayjs": "catalog:",
"grammy": "^1.38.1", "grammy": "^1.38.1",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"pino": "^9.9.0", "pino": "^9.9.0",
"pino-pretty": "^13.1.1", "pino-pretty": "^13.1.1",
"radashi": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "catalog:", "typescript": "catalog:",
"zod": "catalog:" "zod": "catalog:"

View File

@ -1,6 +1,7 @@
/* eslint-disable id-length */ /* eslint-disable id-length */
import { type Context } from '@/bot/context'; import { type Context } from '@/bot/context';
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards'; import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { combine } from '@/utils/messages';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone'; import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation } from '@grammyjs/conversations'; import { type Conversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers'; import { CustomersService } from '@repo/graphql/api/customers';
@ -24,7 +25,11 @@ export async function addContact(conversation: Conversation<Context, Context>, c
} }
// Просим отправить контакт клиента // Просим отправить контакт клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact'))); await ctx.reply(
await conversation.external(({ t }) =>
combine(t('msg-send-client-contact'), t('msg-cancel-operation')),
),
);
// Ждем любое сообщение от пользователя // Ждем любое сообщение от пользователя
const waitCtx = await conversation.wait(); const waitCtx = await conversation.wait();

View File

@ -1 +1,2 @@
export * from './add-contact'; export * from './add-contact';
export * from './subscription';

View File

@ -0,0 +1,101 @@
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { formatMoney } from '@/utils/format';
import { combine } from '@/utils/messages';
import { type Conversation } from '@grammyjs/conversations';
import { fmt, i } from '@grammyjs/parse-mode';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import * as GQL from '@repo/graphql/types';
import { InlineKeyboard } from 'grammy';
import { sift } from 'radashi';
export async function subscription(conversation: Conversation<Context, Context>, ctx: Context) {
const telegramId = ctx.from?.id;
if (!telegramId) {
return replyError(ctx, conversation);
}
const subscriptionsService = new SubscriptionsService({ telegramId });
const hasUserTrial = await subscriptionsService.hasUserTrialSubscription();
const { subscriptionPrices } = await subscriptionsService.getSubscriptionPrices({
filters: {
isActive: {
eq: true,
},
period: {
ne: hasUserTrial ? GQL.Enum_Subscriptionprice_Period.Trial : undefined,
},
},
});
const prices = sift(subscriptionPrices);
// строим клавиатуру
const keyboard = buildPricesKeyboard(prices);
// сообщение с выбором плана
const messageWithPrices = await ctx.reply(
combine(
await conversation.external(({ t }) =>
combine(t('msg-subscribe'), fmt`${i}${t('msg-cancel-operation')}${i}`.text),
),
),
{ reply_markup: keyboard },
);
// ждём выбора
const selectPlanWaitCtx = await conversation.wait();
// удаляем сообщение с выбором (не обязательно, но красивее)
try {
await ctx.api.deleteMessage(telegramId, messageWithPrices.message_id);
} catch {
/* игнорируем, если не удалось удалить */
}
const selectedPeriod = selectPlanWaitCtx.callbackQuery?.data;
if (!selectedPeriod) return replyError(ctx, conversation);
const selectedPrice = prices.find((price) => price?.period === selectedPeriod);
if (!selectedPrice) return replyError(ctx, conversation);
// создаём invoice
return ctx.replyWithInvoice(
'Оплата подписки',
selectedPrice.description || '',
JSON.stringify({ period: selectedPrice.period }),
'RUB',
[
{
amount: selectedPrice.amount * 100, // Telegram ждёт в копейках
label: selectedPrice.description || 'К оплате',
},
],
{
provider_token: env.BOT_PROVIDER_TOKEN,
start_parameter: 'get_access',
},
);
}
// --- helpers ---
function buildPricesKeyboard(prices: GQL.SubscriptionPriceFieldsFragment[]) {
const keyboard = new InlineKeyboard();
for (const price of prices) {
keyboard.row({
callback_data: price.period,
pay: true,
text: `${price.description} (${formatMoney(price.amount)})`,
});
}
return keyboard;
}
async function replyError(ctx: Context, conversation: Conversation<Context, Context>) {
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
}

View File

@ -2,4 +2,5 @@ export * from './add-contact';
export * from './help'; export * from './help';
export * from './registration'; export * from './registration';
export * from './share-bot'; export * from './share-bot';
export * from './subscription';
export * from './welcome'; export * from './welcome';

View File

@ -0,0 +1,44 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { logger } from '@/utils/logger';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
// Telegram требует отвечать на pre_checkout_query
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
await ctx.answerPreCheckoutQuery(true);
});
const feature = composer.chatType('private');
// команда для входа в flow подписки
feature.command('subscribe', logHandle('command-subscribe'), async (ctx) => {
await ctx.conversation.enter('subscription');
});
// успешная оплата
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
const telegramId = ctx.from.id;
const subscriptionsService = new SubscriptionsService({ telegramId });
try {
const rawPayload = ctx.message?.successful_payment.invoice_payload;
if (!rawPayload) throw new Error('Missing invoice payload');
const payload = JSON.parse(rawPayload);
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(payload);
await ctx.reply(ctx.t('msg-subscribe-success'));
await ctx.reply(ctx.t('msg-subscribe-active-until', { date: formattedDate }));
} catch (error) {
await ctx.reply(ctx.t('msg-subscribe-error'));
logger.error(
'Failed to process subscription after successful payment\n' + (error as Error)?.message,
);
}
});
export { composer as subscription };

View File

@ -5,7 +5,7 @@ import { type LanguageCode } from '@grammyjs/types';
import { type Api, type Bot, type RawApi } from 'grammy'; import { type Api, type Bot, type RawApi } from 'grammy';
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) { export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
const commands = createCommands(['start', 'addcontact', 'sharebot', 'help']); const commands = createCommands(['start', 'addcontact', 'sharebot', 'help', 'subscribe']);
for (const command of commands) { for (const command of commands) {
addLocalizations(command); addLocalizations(command);

View File

@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
export const envSchema = z.object({ export const envSchema = z.object({
BOT_PROVIDER_TOKEN: z.string(),
BOT_TOKEN: z.string(), BOT_TOKEN: z.string(),
BOT_URL: z.string(), BOT_URL: z.string(),
RATE_LIMIT: z RATE_LIMIT: z

View File

@ -0,0 +1,4 @@
export const formatMoney = Intl.NumberFormat('ru-RU', {
currency: 'RUB',
style: 'currency',
}).format;

View File

@ -91,7 +91,7 @@ export default async function ProPage() {
<Users className="size-5 text-purple-600 dark:text-purple-400" /> <Users className="size-5 text-purple-600 dark:text-purple-400" />
</div> </div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300"> <p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Ваш профиль доступен всем пользователям Ваш профиль доступен всем пользователям в поиске
</p> </p>
</div> </div>
@ -100,7 +100,7 @@ export default async function ProPage() {
<Star className="size-5 text-purple-600 dark:text-purple-400" /> <Star className="size-5 text-purple-600 dark:text-purple-400" />
</div> </div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300"> <p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Профиль и аватар выделяются специальным цветом Профиль и аватар выделяются цветом
</p> </p>
</div> </div>
</div> </div>

View File

@ -15,6 +15,91 @@ export const ERRORS = {
}; };
export class SubscriptionsService extends BaseService { export class SubscriptionsService extends BaseService {
async createOrUpdateSubscription(payload: { period: GQL.Enum_Subscriptionprice_Period }) {
// ищем цену по выбранному периоду
const { subscriptionPrices } = await this.getSubscriptionPrices({
filters: { period: { eq: payload.period } },
});
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;
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,
customer: customer.documentId,
expiresAt: expiresAtNew,
isActive: true,
},
});
expiresAt = result?.createSubscription?.expiresAt ?? expiresAtNew;
// создаём запись в истории
await this.createSubscriptionHistory({
data: {
amount: subscriptionPrice.amount,
currency: 'RUB',
description: subscriptionPrice.description ?? 'Новая подписка',
endDate: expiresAt,
period: subscriptionPrice.period,
source: GQL.Enum_Subscriptionhistory_Source.Payment,
startDate: dayjs().toISOString(),
state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: result?.createSubscription?.documentId,
},
});
}
return {
expiresAt,
formattedDate: dayjs(expiresAt).toDate().toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
};
}
async createSubscription(variables: VariablesOf<typeof GQL.CreateSubscriptionDocument>) { async createSubscription(variables: VariablesOf<typeof GQL.CreateSubscriptionDocument>) {
await this.checkIsBanned(); await this.checkIsBanned();
@ -54,23 +139,17 @@ export class SubscriptionsService extends BaseService {
const { customer } = await this.checkIsBanned(); const { customer } = await this.checkIsBanned();
// Проверяем, не использовал ли пользователь уже пробный период // Проверяем, не использовал ли пользователь уже пробный период
const { subscription: existingSubscription } = await this.getSubscription({ const hasUserTrial = await this.hasUserTrialSubscription();
telegramId: customer?.telegramId, if (hasUserTrial) throw new Error(ERRORS.TRIAL_PERIOD_ALREADY_USED);
});
if (existingSubscription) {
// Проверяем, есть ли в истории успешная пробная подписка
const hasUsedTrial = existingSubscription.subscriptionHistories?.some(
(item) =>
item?.period === GQL.Enum_Subscriptionhistory_Period.Trial &&
item?.state === GQL.Enum_Subscriptionhistory_State.Success,
);
if (hasUsedTrial) {
throw new Error(ERRORS.TRIAL_PERIOD_ALREADY_USED);
}
}
// Получаем цены подписки для определения длительности пробного периода // Получаем цены подписки для определения длительности пробного периода
const { subscriptionPrices } = await this.getSubscriptionPrices({ isActive: true }); const { subscriptionPrices } = await this.getSubscriptionPrices({
filters: {
isActive: {
eq: true,
},
},
});
if (!subscriptionPrices) throw new Error(ERRORS.SUBSCRIPTION_PRICES_NOT_FOUND); if (!subscriptionPrices) throw new Error(ERRORS.SUBSCRIPTION_PRICES_NOT_FOUND);
// Ищем пробный период // Ищем пробный период
@ -86,7 +165,7 @@ export class SubscriptionsService extends BaseService {
// Создаем пробную подписку // Создаем пробную подписку
const subscriptionData = await this.createSubscription({ const subscriptionData = await this.createSubscription({
input: { data: {
autoRenew: false, autoRenew: false,
customer: customer?.documentId, customer: customer?.documentId,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
@ -102,13 +181,13 @@ export class SubscriptionsService extends BaseService {
// Создаем запись в истории подписки // Создаем запись в истории подписки
await this.createSubscriptionHistory({ await this.createSubscriptionHistory({
input: { data: {
amount: 0, amount: 0,
currency: 'RUB', currency: 'RUB',
description: `Пробный период на ${trialPeriodDays} дней`, description: `Пробный период на ${trialPeriodDays} дней`,
endDate: expiresAt.toISOString(), endDate: expiresAt.toISOString(),
period: GQL.Enum_Subscriptionhistory_Period.Trial, period: GQL.Enum_Subscriptionhistory_Period.Trial,
source: GQL.Enum_Subscriptionhistory_Source.Promo, source: GQL.Enum_Subscriptionhistory_Source.Trial,
startDate: now.toISOString(), startDate: now.toISOString(),
state: GQL.Enum_Subscriptionhistory_State.Success, state: GQL.Enum_Subscriptionhistory_State.Success,
subscription: subscription.documentId, subscription: subscription.documentId,
@ -181,6 +260,27 @@ export class SubscriptionsService extends BaseService {
return result.data; 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>) { async updateSubscription(variables: VariablesOf<typeof GQL.UpdateSubscriptionDocument>) {
await this.checkIsBanned(); await this.checkIsBanned();

View File

@ -3,12 +3,6 @@ fragment SubscriptionFields on Subscription {
isActive isActive
expiresAt expiresAt
autoRenew autoRenew
customer {
...CustomerFields
}
subscriptionHistories {
...SubscriptionHistoryFields
}
} }
fragment SubscriptionHistoryFields on SubscriptionHistory { fragment SubscriptionHistoryFields on SubscriptionHistory {
@ -34,7 +28,7 @@ fragment SubscriptionPriceFields on SubscriptionPrice {
documentId documentId
period period
days days
price amount
currency currency
isActive isActive
description description
@ -46,9 +40,6 @@ fragment SubscriptionRewardFields on SubscriptionReward {
expiresAt expiresAt
activated activated
description description
subscription_history {
...SubscriptionHistoryFields
}
owner { owner {
...CustomerFields ...CustomerFields
} }
@ -69,23 +60,20 @@ query getSubscriptionSettings {
} }
} }
query GetSubscriptionPrices($isActive: Boolean) { query GetSubscriptionPrices($filters: SubscriptionPriceFiltersInput) {
subscriptionPrices(filters: { isActive: { eq: $isActive } }, sort: "price:asc") { subscriptionPrices(filters: $filters, sort: "amount:asc") {
...SubscriptionPriceFields ...SubscriptionPriceFields
} }
} }
query GetSubscriptionHistory($subscriptionId: ID!) { query GetSubscriptionHistory($filters: SubscriptionHistoryFiltersInput) {
subscriptionHistories( subscriptionHistories(filters: $filters) {
filters: { subscription: { documentId: { eq: $subscriptionId } } }
sort: "startDate:desc"
) {
...SubscriptionHistoryFields ...SubscriptionHistoryFields
} }
} }
mutation CreateSubscription($input: SubscriptionInput!) { mutation CreateSubscription($data: SubscriptionInput!) {
createSubscription(data: $input) { createSubscription(data: $data) {
...SubscriptionFields ...SubscriptionFields
} }
} }
@ -96,8 +84,8 @@ mutation UpdateSubscription($documentId: ID!, $data: SubscriptionInput!) {
} }
} }
mutation CreateSubscriptionHistory($input: SubscriptionHistoryInput!) { mutation CreateSubscriptionHistory($data: SubscriptionHistoryInput!) {
createSubscriptionHistory(data: $input) { createSubscriptionHistory(data: $data) {
...SubscriptionHistoryFields ...SubscriptionHistoryFields
} }
} }

File diff suppressed because one or more lines are too long

6
pnpm-lock.yaml generated
View File

@ -141,6 +141,9 @@ importers:
'@types/node': '@types/node':
specifier: 'catalog:' specifier: 'catalog:'
version: 20.19.4 version: 20.19.4
dayjs:
specifier: 'catalog:'
version: 1.11.13
grammy: grammy:
specifier: ^1.38.1 specifier: ^1.38.1
version: 1.38.1 version: 1.38.1
@ -153,6 +156,9 @@ importers:
pino-pretty: pino-pretty:
specifier: ^13.1.1 specifier: ^13.1.1
version: 13.1.1 version: 13.1.1
radashi:
specifier: 'catalog:'
version: 12.5.1
tsup: tsup:
specifier: ^8.5.0 specifier: ^8.5.0
version: 8.5.0(jiti@2.4.1)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0) version: 8.5.0(jiti@2.4.1)(postcss@8.5.6)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0)

View File

@ -12,7 +12,8 @@
"LOGIN_GRAPHQL", "LOGIN_GRAPHQL",
"BOT_TOKEN", "BOT_TOKEN",
"NEXTAUTH_SECRET", "NEXTAUTH_SECRET",
"BOT_URL" "BOT_URL",
"BOT_PROVIDER_TOKEN"
] ]
}, },
"lint": { "lint": {