Платежи

В этом разделе описывается, как настроить и использовать платежную систему Stripe в Boilerplate. Вы узнаете, как обрабатывать платежи, управлять подписками и интегрировать платежные формы в ваше приложение.

Обзор платежной системы

Boilerplate использует Stripe для обработки платежей. Stripe — это популярный платежный сервис, который позволяет принимать платежи онлайн, управлять подписками и многое другое.

Настройка Stripe

Для начала работы со Stripe вам необходимо:

  1. Создать аккаунт на Stripe

  2. Получить API ключи в панели управления Stripe (тестовые и боевые)

  3. Добавить ключи 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:

python
# 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 для отображения платежных форм:

tsx
// 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 вы можете создать клиент:

tsx
// 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 позволяет пользователям управлять своими подписками:

tsx
// 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 эндпоинт для создания портальной сессии:

typescript
// 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:

tsx
// 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 маршрут для обработки вебхуков:

typescript
// 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 или функции-помощники:

typescript
// 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.

Советы и рекомендации

  • Всегда проверяйте подписи вебхуков для обеспечения безопасности
  • Используйте идемпотентность для предотвращения дублирования платежей
  • Тестируйте различные сценарии оплаты перед запуском в продакшн
  • Реализуйте уведомления для пользователей о статусе их подписок
  • Настройте обработку отказов платежей и уведомления для повторных попыток

Дополнительные ресурсы