Платежи
В этом разделе описывается, как настроить и использовать платежную систему Stripe в Boilerplate. Вы узнаете, как обрабатывать платежи, управлять подписками и интегрировать платежные формы в ваше приложение.
Обзор платежной системы
Boilerplate использует Stripe для обработки платежей. Stripe — это популярный платежный сервис, который позволяет принимать платежи онлайн, управлять подписками и многое другое.
Настройка Stripe
Для начала работы со Stripe вам необходимо:
Создать аккаунт на Stripe
Получить API ключи в панели управления Stripe (тестовые и боевые)
Добавить ключи API в переменные окружения вашего проекта
bash# .env.local NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key STRIPE_SECRET_KEY=sk_test_your_secret_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Интеграция Stripe в Backend
Backend часть Boilerplate содержит API эндпоинты для работы со Stripe:
# backend/app/api/routes/stripe.py
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
import stripe
from app.api.deps import get_current_user, get_db
from app.core.config import settings
from app.models.user import User
from app.schemas.stripe import CheckoutSession, SubscriptionStatus
# Настройка Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
router = APIRouter()
@router.post("/create-checkout-session", response_model=CheckoutSession)
async def create_checkout_session(
*,
price_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Any:
"""
Создать сессию оформления заказа Stripe.
"""
try:
checkout_session = stripe.checkout.Session.create(
customer_email=current_user.email,
line_items=[
{
"price": price_id,
"quantity": 1,
},
],
mode="subscription",
success_url=f"{settings.FRONTEND_URL}/account?success=true",
cancel_url=f"{settings.FRONTEND_URL}/pricing?canceled=true",
metadata={
"user_id": str(current_user.id),
},
)
return {"checkout_url": checkout_session.url}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.post("/webhook", status_code=status.HTTP_200_OK)
async def stripe_webhook(
request: Request,
db: AsyncSession = Depends(get_db),
) -> Any:
"""
Обработчик вебхуков Stripe.
"""
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверная подпись")
# Обработка событий
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await handle_checkout_session_completed(db, session)
elif event["type"] == "customer.subscription.updated":
subscription = event["data"]["object"]
await handle_subscription_updated(db, subscription)
elif event["type"] == "customer.subscription.deleted":
subscription = event["data"]["object"]
await handle_subscription_deleted(db, subscription)
return {"status": "success"}
Интеграция Stripe в Frontend
На Frontend стороне вы можете использовать компоненты Stripe для отображения платежных форм:
// frontend/components/payment/CheckoutButton.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
interface CheckoutButtonProps {
priceId: string;
text: string;
}
export function CheckoutButton({ priceId, text }: CheckoutButtonProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleCheckout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/stripe/create-checkout-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ price_id: priceId }),
});
if (!response.ok) {
throw new Error("Ошибка при создании сессии Stripe");
}
const { checkout_url } = await response.json();
router.push(checkout_url);
} catch (error) {
console.error("Ошибка оформления заказа:", error);
setIsLoading(false);
}
};
return (
<Button
onClick={handleCheckout}
disabled={isLoading}
className="w-full"
>
{isLoading ? "Загрузка..." : text}
</Button>
);
}
API клиент для Stripe
Для удобства работы со Stripe API вы можете создать клиент:
// frontend/lib/stripe.ts
import { loadStripe } from "@stripe/stripe-js";
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
// Для серверных компонентов
export async function createCheckoutSession(priceId: string) {
const response = await fetch("/api/stripe/create-checkout-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ price_id: priceId }),
});
if (!response.ok) {
throw new Error("Ошибка при создании сессии Stripe");
}
return response.json();
}
export async function getSubscriptionStatus() {
const response = await fetch("/api/stripe/subscription-status");
if (!response.ok) {
return { isSubscribed: false };
}
return response.json();
}
Интеграция с порталом клиентов Stripe
Портал клиентов Stripe позволяет пользователям управлять своими подписками:
// frontend/components/account/ManageSubscriptionButton.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function ManageSubscriptionButton() {
const [isLoading, setIsLoading] = useState(false);
const handleManageSubscription = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/stripe/create-portal-session", {
method: "POST",
});
if (!response.ok) {
throw new Error("Ошибка при создании портальной сессии");
}
const { url } = await response.json();
window.location.href = url;
} catch (error) {
console.error("Ошибка:", error);
setIsLoading(false);
}
};
return (
<Button
onClick={handleManageSubscription}
disabled={isLoading}
variant="outline"
>
{isLoading ? "Загрузка..." : "Управление подпиской"}
</Button>
);
}
API эндпоинт для создания портальной сессии:
// frontend/app/api/stripe/create-portal-session/route.ts
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import Stripe from "stripe";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}
const user = await db.user.findUnique({
where: { email: session.user.email! },
});
if (!user || !user.stripeCustomerId) {
return new Response("No Stripe customer found", { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error("Error creating Stripe portal session:", error);
return new Response("Error creating Stripe portal session", { status: 500 });
}
}
Компонент для отображения цен
Для отображения тарифных планов вы можете использовать компонент PricingTable:
// frontend/components/pricing/PricingTable.tsx
import { getSubscriptionStatus } from "@/lib/stripe";
import { CheckoutButton } from "./CheckoutButton";
interface Plan {
name: string;
description: string;
price: string;
features: string[];
priceId: string;
}
const plans: Plan[] = [
{
name: "Базовый",
description: "Все необходимые функции для начала работы",
price: "990 ₽/мес",
features: [
"Доступ к основным функциям",
"До 10 проектов",
"Базовая поддержка",
],
priceId: "price_1AbCdEfGhIjKlMnOpQrStUvW",
},
{
name: "Премиум",
description: "Идеально для растущих компаний",
price: "2 490 ₽/мес",
features: [
"Все функции Базового плана",
"Неограниченное количество проектов",
"Приоритетная поддержка",
"Расширенная аналитика",
],
priceId: "price_2BcDeFgHiJkLmNoPqRsTuVwX",
},
];
export async function PricingTable() {
const subscription = await getSubscriptionStatus();
return (
<div className="grid gap-6 sm:grid-cols-2">
{plans.map((plan) => (
<div
key={plan.name}
className="flex flex-col p-6 bg-gray-900 rounded-lg border border-gray-800"
>
<h3 className="text-xl font-bold">{plan.name}</h3>
<p className="mt-2 text-gray-400">{plan.description}</p>
<p className="mt-4 text-3xl font-bold">{plan.price}</p>
<ul className="mt-4 space-y-2 flex-1">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center">
<span className="mr-2">✅</span>
{feature}
</li>
))}
</ul>
<div className="mt-6">
{subscription.isSubscribed ? (
<p className="text-sm text-center text-gray-400">
У вас уже есть активная подписка
</p>
) : (
<CheckoutButton
priceId={plan.priceId}
text={`Подписаться на ${plan.name}`}
/>
)}
</div>
</div>
))}
</div>
);
}
Обработка вебхуков Stripe
Для обработки событий Stripe (успешные платежи, изменения подписок и т.д.) используются вебхуки. API маршрут для обработки вебхуков:
// frontend/app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("Stripe-Signature");
let event: Stripe.Event;
try {
if (!signature) throw new Error("Missing signature");
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (error: any) {
console.error(`Webhook Error: ${error.message}`);
return new Response(`Webhook Error: ${error.message}`, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed":
const checkoutSession = event.data.object as Stripe.Checkout.Session;
// Обработка успешной оплаты
if (checkoutSession.metadata?.userId) {
const userId = checkoutSession.metadata.userId;
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: checkoutSession.customer as string,
stripeSubscriptionId: checkoutSession.subscription as string,
isPro: true,
},
});
}
break;
case "customer.subscription.updated":
case "customer.subscription.deleted":
const subscription = event.data.object as Stripe.Subscription;
// Обновление статуса подписки
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
isPro: subscription.status === "active",
},
});
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook error:", error);
return new Response("Webhook handler failed", { status: 500 });
}
}
Проверка статуса подписки
Для проверки, имеет ли пользователь активную подписку, можно использовать middleware или функции-помощники:
// frontend/lib/subscription.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function checkSubscription() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return false;
}
const user = await db.user.findUnique({
where: { email: session.user.email! },
select: { isPro: true },
});
return user?.isPro ?? false;
}
// Использование в серверном компоненте
// app/premium-content/page.tsx
import { redirect } from "next/navigation";
import { checkSubscription } from "@/lib/subscription";
export default async function PremiumContentPage() {
const hasSubscription = await checkSubscription();
if (!hasSubscription) {
redirect("/pricing?required=true");
}
return (
<div>
<h1>Премиум контент</h1>
{/* Содержимое только для подписчиков */}
</div>
);
}
Тестирование платежей
Для тестирования платежей Stripe предоставляет тестовые карты. Вот некоторые из них:
- 4242 4242 4242 4242 → Успешный платеж
- 4000 0000 0000 3220 → Требуется 3D Secure
- 4000 0000 0000 9995 → Платеж отклонен
Для всех тестовых карт вы можете использовать любую будущую дату для срока действия, любой трехзначный CVC.
Советы и рекомендации
- Всегда проверяйте подписи вебхуков для обеспечения безопасности
- Используйте идемпотентность для предотвращения дублирования платежей
- Тестируйте различные сценарии оплаты перед запуском в продакшн
- Реализуйте уведомления для пользователей о статусе их подписок
- Настройте обработку отказов платежей и уведомления для повторных попыток