bulutyerli logo

Stripe Payment Elements ile Next.js App Router & Webhooks & Typescript Entegresi

24 Temmuz 2024Stripe Payment Elements ile Next.js App Router & Webhooks & Typescript Entegresi

Giriş:

Projelerimden birine Stripe'ı entegre etmek istediğimde, resmi dökümantasyonun Pages Router için hazırlandığını fark ettim. Stripe'ı Next.js App Router ile entegre etmeye yönelik birçok çevrimiçi eğitim var, ancak çoğu ya uygulamanın tamamını istemci tarafında yapıyor ya da işlevsel bir başarı sayfasına yönlendirmeyi düzgün bir şekilde yapamıyor.

Bu eğitimde amacım, bu durumu doğru bir şekilde ele almak.

Örneklerimde Postgres kodları olabilir çünkü ürünler ve sipariş veritabanını kendim yönetiyorum. Bunları kasıtlı olarak silmedim, ancak her zaman kendi çözümünüzü uygulayabilirsiniz.

İlk Yapılması Gereken Şey:

Bir Stripe hesabı oluşturmanız ve şifrelerinizi bir env dosyasına yazmanız gerekiyor.

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_SECRET_WEBHOOK_KEY=""

Ödeme Uç Noktası Oluşturma

İlk olarak, API klasörümde bir route.ts dosyası oluşturuyorum. Bu dosyada, sepet öğelerini işliyor ve ürün veritabanımı kullanarak toplam fiyatı hesaplıyorum. Bu hesaplama, kullanıcıların istemci tarafında tutarı manipüle etmesini önlemek için sunucu tarafında yapılmalıdır. Ayrıca, kullanıcının sunucu tarafında kimlik doğrulamasını sağlıyorum.

//src/app/api/payment/route.ts

import { db } from '@/src/database';
import { CartItem } from '@/src/types';
import { authenticatedUser } from '@/src/utils/amplify-server-utils';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  typescript: true,
});

const calculateOrderAmount = async (items: CartItem[]) => {
  const productIds = items.map((item) => item.id);
  const prices = await db
    .selectFrom('product_variants')
    .select(['price', 'id'])
    .where('product_variants.id', 'in', productIds)
    .execute();

  const totalPrice = items.reduce((total, item) => {
    const product = prices.find((price) => price.id === item.id);
    return total + (product ? product.price * item.quantity : 0);
  }, 0);

  return Math.round(totalPrice * 100);
};

export async function POST(request: NextRequest) {
  try {
    const {
      items,
      address_id,
    }: { items: CartItem[] | null; address_id: number } = await request.json();
    const response = NextResponse.next();
    const user = await authenticatedUser({ request, response });

    if (!user) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }

    if (!items) {
      return Response.json({ error: 'Invalid Items' }, { status: 400 });
    }

    const total = await calculateOrderAmount(items);

    const paymentIntent = await stripe.paymentIntents.create({
      amount: total,
      currency: 'usd',
      automatic_payment_methods: {
        enabled: true,
      },
      metadata: {
        userId: user.userId,
        items: JSON.stringify(items),
        address_id,
      },
    });

    return Response.json({
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    console.log(error);
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Ödeme Formu Oluşturma

Öncelikle, Stripe'tan PaymentElement'i kullanan bir ödeme formu oluşturuyorum. Bu form, kullanıcıların kart bilgilerini girmelerine (veya diğer ödeme seçeneklerini seçmelerine) ve ödemelerini tamamlamalarına olanak tanır. return_url burada çok önemli; ödeme başarılı olduğunda kullanıcıyı bu sayfaya yönlendirecektir. Bu sayfayı daha sonra oluşturacağız.

//src/components/CheckoutForm.tsx

'use client';

import { useState } from 'react';
import styles from './checkoutForm.module.scss';
import {
  PaymentElement,
  useElements,
  useStripe,
} from '@stripe/react-stripe-js';
import { StripePaymentElementOptions } from '@stripe/stripe-js';

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [message, setMessage] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (!stripe || !elements) {
      return;
    }

    setIsLoading(true);

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: 'http://localhost:3000/checkout/payment-status',
      },
    });

    if (error) {
      if (error.type === 'card_error' || error.type === 'validation_error') {
        setMessage(error.message || 'An error occurred.');
      } else {
        setMessage('An unexpected error occurred.');
      }
    }

    setIsLoading(false);
  };

  const paymentElementOptions: StripePaymentElementOptions = {
    layout: 'tabs',
  };

  return (
    <form
      id="payment-form"
      className={styles.paymentForm}
      onSubmit={handleSubmit}
    >
      <PaymentElement id="payment-element" options={paymentElementOptions} />
      <button
        className={styles.submitButton}
        disabled={isLoading || !stripe || !elements}
        id="submit"
      >
        <span id="button-text">
          {isLoading ? (
            <div className={styles.spinner} id="spinner"></div>
          ) : (
            'Pay now'
          )}
        </span>
      </button>
      {message && (
        <div id="payment-message" className={styles.paymentMessage}>
          {message}
        </div>
      )}
    </form>
  );
}

Ödeme Sayfası Oluşturma

Bu aşamada, üç dosya oluşturmamız gerekiyor. Bir checkout klasörü oluşturdum ve layout.tsx dosyası ekledim. Bir layout kullanıyorum çünkü Stripe provider kullanmamız gerekiyor. Bu şekilde, kullanıcıların ödeme formundan yönlendirildiği ödeme durumu sayfası da aynı provider içinde olacak. Böylece layout içerisindeki tüm sayfalar, Stripe'dan gelen mesajları eş zamanlı alabiliyor olacak.

layout.tsx dosyasında ayrıca clientSecret olmadığında kullanıcıları ana sayfaya yönlendiriyorum. Böylece, ödeme bölümünde satın almaya hazır öğeler olmadığında ödeme sayfasının gösterilmemesini sağlayacak.

//src/app/checkout/layout.tsx

'use client';

import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { useEffect, useState } from 'react';
import { useSelector } from '@/src/redux/store';
import { selectAddress, setAddresses } from '@/src/redux/slices/addressSlice';
import { useRouter } from 'next/navigation';

loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)

export default function CheckoutLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [clientSecret, setClientSecret] = useState('');
  const { items } = useSelector((state) => state.order);
  const router = useRouter();

  useEffect(() => {
      fetch('/api/payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      })
        .then((res) => res.json())
        .then((data) => {
          if (data.clientSecret) {
            setClientSecret(data.clientSecret);
          } else {
            router.push('/');
          }
        });
    
  }, []);

  const options = {
    clientSecret,
  };

  return (
    <main>
      {clientSecret && (
        <Elements key={clientSecret} options={options} stripe={stripePromise}>
          {children}
        </Elements>
      )}
    </main>
  );
}

layout.tsx ile aynı seviyede, bir page.tsx dosyası oluşturuyorum. Bu dosya, CheckoutForm bileşenini kullanacak.

//src/checkout/page.tsx

'use client';

import CheckoutForm from '@/src/components/CheckoutForm/CheckoutForm';
import styles from './checkout.module.scss';

export default function CheckoutPage() {
  return (
    <main className={styles.main} color="white">
      <h1>Checkout</h1>
         <div className={styles.checkoutForm}>
            <CheckoutForm />
         </div>   
    </main>
  );
}

Ödeme Durumu Sayfası Oluşturma

checkoutForm'un return_url olarak belirtilen sayfaya yönlendireceği bir sayfa oluşturmalıyız. Birçok kişi bu sayfanın sadece bir "ödeme başarılı" mesajı göstermek için olduğunu düşünüyor, ancak aslında başka amaçları da var.

checkoutForm' içerisinde anlık form doğrulama hatalarını gösterebilirsiniz, ancak diğer ödeme sorunları bu return_url sayfasında yakalanmalıdır. Stripe, kullanıcıları yönlendirirken URL'ye ek parametreler ekler, bu nedenle ödeme durumunu kontrol etmek ve kullanıcılara uygun mesajları göstermek için bu sayfada retrievePaymentIntent kullanırız.

Bu sayfa ayrıca kullanıcılara ödeme sayfasına geri yönlendirme yapabilir, ödeme başarılıysa sepeti temizleyebilir veya ödeme sonucuna bağlı olarak diğer görevleri yerine getirebilir.

//src/app/checkout/payment-status/page.tsx

'use client';

import { useState, useEffect } from 'react';
import { useStripe } from '@stripe/react-stripe-js';
import styles from './complete.module.scss';
import { useDispatch } from '@/src/redux/store';
import { clearCart } from '@/src/redux/slices/cartSlice';
import Container from '@/src/components/Container/Container';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { clearOrder } from '@/src/redux/slices/orderSlice';

export default function PaymentStatusPage() {
  const [message, setMessage] = useState({ message: '', success: false });
  const stripe = useStripe();
  const dispatch = useDispatch();
  const router = useRouter();

  useEffect(() => {
    if (!stripe) {
      return;
    }

    const clientSecret = new URLSearchParams(window.location.search).get(
      'payment_intent_client_secret'
    );

    if (!clientSecret) {
      router.push('/');
      return;
    }

    stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
      if (paymentIntent) {
        switch (paymentIntent.status) {
          case 'succeeded':
            dispatch(clearOrder());
            dispatch(clearCart());
            setMessage({
              message:
                'Thank you for your purchase! Your payment was successful',
              success: true,
            });
            break;
          case 'processing':
            setMessage({
              message: 'Your payment is currently being processed',
              success: false,
            });
            break;
          case 'requires_payment_method':
            setMessage({
              message: 'Unfortunately, your payment could not be completed',
              success: false,
            });
            router.push('/checkout');
            break;
          default:
            setMessage({
              message:
                'An unexpected error occurred. Please contact our support team for assistance.',
              success: false,
            });
            break;
        }
      }
    });
  }, [stripe]);

  return (
      <div className={styles.container}>
        <h1>{message.message}</h1>
        {message.success && (
          <div className={styles.orderLinkContainer}>
            <h2>You can check your order and shipping status from here:</h2>
            <Link href="/account">Go to My Account</Link>
          </div>
        )}
      </div>
  );
}

Webhook Oluşturma

Şimdi bir webhook oluşturmalıyız. Webhooklar, Stripe etkinliklerini gerçek zamanlı olarak yönetmek için çok önemlidir. Yeni siparişler oluşturma, müşterilerinize onay e-postaları gönderme ve daha fazlası gibi görevleri yönetirler. Bu eğitimde, veritabanımda siparişler oluşturmak ve satın alınan öğeleri kaydetmek için bir webhook kullanıyorum.

Not:

Webhook, daha önce oluşturduğumuz payment/route.ts dosyasından meta verileri alır.

//src/app/api/webhook/route.ts

import Stripe from 'stripe';
import { NextRequest } from 'next/server';
import { headers } from 'next/headers';
import { db } from '@/src/database';
import { CartItem } from '@/src/types';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  typescript: true,
});

export async function POST(request: NextRequest) {
  const body = await request.text();
  const endpointSecret = process.env.STRIPE_SECRET_WEBHOOK_KEY!;
  const sig = headers().get('stripe-signature') as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Webhook Error: ${err.message}`);
    return Response.json(`Webhook Error: ${err.message}`, { status: 400 });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;

      if (
        !paymentIntent.metadata ||
        !paymentIntent.metadata.userId ||
        !paymentIntent.metadata.items ||
        !paymentIntent.metadata.address_id
      ) {
        console.error('Missing metadata or userId/items/address_id');
        return Response.json('Bad Request: Missing metadata', { status: 400 });
      }

      const userId = paymentIntent.metadata.userId;
      const items: CartItem[] = JSON.parse(paymentIntent.metadata.items);
      const addressId: number = parseInt(paymentIntent.metadata.address_id, 10);

      try {
        await db.transaction().execute(async (trx) => {
          const order = await trx
            .insertInto('orders')
            .values({
              user_sub: userId,
              total_price: paymentIntent.amount_received
                ? paymentIntent.amount_received / 100
                : 0,
              order_date: new Date(),
              address_id: addressId,
            })
            .returning('id')
            .executeTakeFirstOrThrow();

          await Promise.all(
            items.map(async (item) => {
              return await trx
                .insertInto('order_items')
                .values({
                  order_id: order.id,
                  product_variant_id: item.id,
                  quantity: item.quantity,
                })
                .returningAll()
                .executeTakeFirst();
            })
          );
        });

        console.log('Order and order items successfully inserted.');
      } catch (error: any) {
        console.error(`Database Error: ${error.message}`);
        return Response.json(`Database Error: ${error.message}`, {
          status: 500,
        });
      }
      break;

    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  return Response.json('Event received', { status: 200 });
}

Webhook'u Test Etme

Webhook'unuzu local olarak test etmek istiyorsanız, Stripe CLI'yi indirmeniz ve webhook route'unu dinlemeniz gerekir.

Stripe CLI indirme linki: https://docs.stripe.com/stripe-cli

Stripe CLI'yı kurduktan sonra terminalinize stripe login yazın. Ardından şu kodu yapıştırın:

"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/webhook"

Uygulamanızı test ederken bu terminali açık tutmanız gerekir.

Production için https://dashboard.stripe.com/webhooks adresini ziyaret etmeli ve web uygulamanız için yeni bir endpoint oluşturmalısınız.

Bu kadar!

Bu eğitimin, Stripe'ı Next.js uygulamanızla kurmanıza yardımcı olduğunu umuyorum. Herhangi bir sorunuz veya daha fazla yardıma ihtiyacınız varsa, lütfen benimle iletişime geçmekten çekinmeyin. Ayrıca, Medium makalesinde yorum veya geri bildirim bırakabilirsiniz. İyi kodlamalar!