使用 Next.js、TypeScript 和 Stripe 实现类型安全支付

2025-06-07

使用 Next.js、TypeScript 和 Stripe 实现类型安全支付

目录

2019 年 StackOverflow 调查中,TypeScript 获得了极大的欢迎,进入了最受欢迎和最受喜爱的语言前十名。

从 8.0.1 版本开始,Stripe 维护最新API 版本的类型,为您提供类型错误、API 字段和参数的自动完成、编辑器内文档等等!

为了支持这种跨堆栈的卓越开发体验,Stripe 还在react-stripe-js库中添加了类型,并遵循 hooks 模式,从而带来令人愉悦且现代化的开发体验。友好的加拿大全栈开发者Wes Bos称赞它“棒极了”,并已将他的高级 React 课程迁移到此,希望您也能很快享受到这种愉悦的体验 🙂

在推特上向我提出您的问题和反馈!

使用 Next.js 设置 TypeScript 项目

使用 Next.js 设置 TypeScript 项目非常方便,因为它会自动生成配置文件。您可以按照文档tsconfig.json中的设置步骤操作,也可以从更完整的示例开始。当然,您也可以在GitHub上找到我们下面详细介绍的完整示例

使用 Next.js 和 Vercel 管理 API 密钥/机密

使用 API 密钥和机密信息时,我们需要确保它们保密且不受版本控制(请务必将其添加.env*.local到您的.gitignore文件中),同时方便地将它们用作env变量。有关环境变量的更多详细信息,请参阅Netx.js 文档

在我们的项目根目录下,我们添加一个文件,并从Stripe Dashboard.env.local提供 Stripe 密钥和机密



# Stripe keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_12345
STRIPE_SECRET_KEY=sk_12345


Enter fullscreen mode Exit fullscreen mode

NEXT_PUBLIC_前缀会自动将此变量公开给浏览器。Next.js 会在构建/渲染时将这些变量的值插入到可公开查看的源代码中。因此,请确保不要将此前缀用于机密值!

ESnext 应用程序的 Stripe.js 加载实用程序

由于PCI 合规性要求,Stripe.js 库必须从 Stripe 的服务器加载。这在使用服务器端渲染的应用时带来了挑战,因为 window 对象在服务器上不可用。为了帮助您管理这种复杂性,Stripe 提供了一个加载包装器,允许您像 ES 模块一样导入 Stripe.js:



import { loadStripe } from '@stripe/stripe-js';

const stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);


Enter fullscreen mode Exit fullscreen mode

Stripe.js 的加载是该声明的副作用import '@stripe/stripe-js';。为了充分利用 Stripe 的高级反欺诈功能,请确保在客户结账流程的每个页面上都加载 Stripe.js,而不仅仅是您的结账页面。这样,Stripe 就可以在客户浏览您的网站时检测到可能表明存在欺诈行为的异常行为。

为了确保 Stripe.js 在所有相关页面上加载,我们创建了一个Layout 组件来加载和初始化 Stripe.js,并将我们的页面包装在 Elements 提供程序中,以便在我们需要的任何地方都可以使用它:



// Partial of components/Layout.tsx
// ...
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';

type Props = {
  title?: string;
};

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

const Layout: React.FunctionComponent<Props> = ({
  children,
  title = 'TypeScript Next.js Stripe Example'
}) => (
  <Elements stripe={stripePromise}>
    <Head>
    {/* ... */}
    </footer>
  </Elements>
);

export default Layout;


Enter fullscreen mode Exit fullscreen mode

处理来自客户端的自定义金额输入

我们通常需要服务器端组件来处理支付,是因为我们不能信任从前端发送的输入。例如,有人可能会打开浏览器开发工具,修改前端发送到后端的金额。因此,总是需要一些服务器端组件来计算/验证应收取的金额。

如果您运营一个纯静态网站(有人提到过JAMstack吗?!),您可以使用 Stripe 的客户端 Checkout功能。我们可以在 Stripe 中创建产品或订阅计划的详细信息,以便 Stripe 为我们执行服务器端验证。您可以在我的GitHub上看到一些使用 Gatsby 的示例

回到手头的话题:在这个例子中,我们希望允许客户指定他们想要捐赠的自定义金额,但是我们想要设置一些限制,我们在以下内容中指定/config/index.ts



export const CURRENCY = 'usd';
// Set your amount limits: Use float for decimal currencies and
// Integer for zero-decimal currencies: https://stripe.com/docs/currencies#zero-decimal.
export const MIN_AMOUNT = 10.0;
export const MAX_AMOUNT = 5000.0;
export const AMOUNT_STEP = 5.0;


Enter fullscreen mode Exit fullscreen mode

使用 Next.js,我们可以方便地对客户端和服务器端(API 路由)组件使用相同的配置文件。在客户端,我们创建一个自定义金额输入字段组件,该组件在 中定义/components/CustomDonationInput.tsx并可如下使用:



// Partial of ./components/CheckoutForm.tsx
// ...
  return (
    <form onSubmit={handleSubmit}>
      <CustomDonationInput
        name={"customDonation"}
        value={input.customDonation}
        min={config.MIN_AMOUNT}
        max={config.MAX_AMOUNT}
        step={config.AMOUNT_STEP}
        currency={config.CURRENCY}
        onChange={handleInputChange}
      />
      <button type="submit">
        Donate {formatAmountForDisplay(input.customDonation, config.CURRENCY)}
      </button>
    </form>
  );
};

export default CheckoutForm;


Enter fullscreen mode Exit fullscreen mode

然后,在我们的服务器端组件,我们验证从客户端发布的金额:



// Partial of ./pages/api/checkout_sessions/index.ts
// ...
export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "POST") {
    const amount: number = req.body.amount;
    try {
      // Validate the amount that was passed from the client.
      if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
        throw new Error("Invalid amount.");
      }
// ...


Enter fullscreen mode Exit fullscreen mode

格式化货币以进行显示并检测零小数货币

在 JavaScript 中,我们可以使用Intl.Numberformat构造函数正确格式化金额和货币符号,并使用formatToParts方法检测零小数货币。为此,我们在中创建了一些辅助方法./utils/stripe-helpers.ts



export function formatAmountForDisplay(
  amount: number,
  currency: string
): string {
  let numberFormat = new Intl.NumberFormat(['en-US'], {
    style: 'currency',
    currency: currency,
    currencyDisplay: 'symbol',
  });
  return numberFormat.format(amount);
}

export function formatAmountForStripe(
  amount: number,
  currency: string
): number {
  let numberFormat = new Intl.NumberFormat(['en-US'], {
    style: 'currency',
    currency: currency,
    currencyDisplay: 'symbol',
  });
  const parts = numberFormat.formatToParts(amount);
  let zeroDecimalCurrency: boolean = true;
  for (let part of parts) {
    if (part.type === 'decimal') {
      zeroDecimalCurrency = false;
    }
  }
  return zeroDecimalCurrency ? amount : Math.round(amount * 100);
}


Enter fullscreen mode Exit fullscreen mode

useStripe 钩子

作为react-stripe-js库的一部分,Stripe 提供了钩子(例如useStripeuseElements)来检索对条纹和元素实例的引用。

如果您不熟悉 React 中的 Hooks 概念,我建议您简单浏览一下“Hooks 一览”

创建 CheckoutSession 并重定向到 Stripe Checkout

Stripe Checkout是开始使用 Stripe 的最快方式,它提供了一个由 Stripe 托管的结帐页面,该页面带有各种付款方式,并开箱即用地支持 Apple Pay 和 Google Pay。

在我们的checkout_sessionAPI 路由中,我们创建了一个带有自定义捐赠金额的 CheckoutSession:



// Partial of ./pages/api/checkout_sessions/index.ts
// ...
// Create Checkout Sessions from body params.
const params: Stripe.Checkout.SessionCreateParams = {
  submit_type: 'donate',
  payment_method_types: ['card'],
  line_items: [
    {
      name: 'Custom amount donation',
      amount: formatAmountForStripe(amount, CURRENCY),
      currency: CURRENCY,
      quantity: 1,
    },
  ],
  success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
};
const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create(
  params
);
// ...


Enter fullscreen mode Exit fullscreen mode

然后,在我们的客户端组件中,我们使用 CheckoutSession id 重定向到 Stripe 托管页面:



// Partial of ./components/CheckoutForm.tsx
// ...
const handleSubmit = async (e: FormEvent) => {
  e.preventDefault();
  // Create a Checkout Session.
  const checkoutSession: Stripe.Checkout.Session = await fetchPostJSON(
    '/api/checkout_sessions',
    { amount: input.customDonation }
  );

  if ((checkoutSession as any).statusCode === 500) {
    console.error((checkoutSession as any).message);
    return;
  }

  // Redirect to Checkout.
  const { error } = await stripe.redirectToCheckout({
    // Make the id field from the Checkout Session creation API response
    // available to this file, so you can provide it as parameter here
    // instead of the {{CHECKOUT_SESSION_ID}} placeholder.
    sessionId: checkoutSession.id,
  });
  // If `redirectToCheckout` fails due to a browser or network
  // error, display the localized error message to your customer
  // using `error.message`.
  console.warn(error.message);
};
// ...


Enter fullscreen mode Exit fullscreen mode

一旦客户在 Stripe 端完成(或取消)付款,他们将被重定向到我们的/pages/result.tsx页面。在这里,我们使用useRouter钩子访问附加到 URL 的 CheckoutSession ID,以检索并打印 CheckoutSession 对象。

由于我们使用的是 TypeScript,我们可以使用一些很棒的 ESnext 语言功能,例如可选链空值合并运算符,这些功能(在撰写本文时)在 JavaScript 中尚不可用。



// Partial of ./pages/result.tsx
// ...
const ResultPage: NextPage = () => {
  const router = useRouter();

  // Fetch CheckoutSession from static page via
  // https://nextjs.org/docs/basic-features/data-fetching#static-generation
  const { data, error } = useSWR(
    router.query.session_id
      ? `/api/checkout_sessions/${router.query.session_id}`
      : null,
    fetchGetJSON
  );

  if (error) return <div>failed to load</div>;

  return (
    <Layout title="Checkout Payment Result | Next.js + TypeScript Example">
      <h1>Checkout Payment Result</h1>
      <h2>Status: {data?.payment_intent?.status ?? 'loading...'}</h2>
      <p>
        Your Checkout Session ID:{' '}
        <code>{router.query.session_id ?? 'loading...'}</code>
      </p>
      <PrintObject content={data ?? 'loading...'} />
      <p>
        <Link href="/">
          <a>Go home</a>
        </Link>
      </p>
    </Layout>
  );
};

export default ResultPage;


Enter fullscreen mode Exit fullscreen mode

使用 Stripe Elements 和 PaymentIntents 在现场获取银行卡详细信息

Stripe Elements是一组预构建的 UI 组件,可最大程度地自定义和控制您的结账流程。您可以在GitHub上找到一系列示例,从中汲取灵感。

React Stripe.js是 Stripe Elements 的薄包装器。它允许我们将元素添加到 React 应用程序。

上面设置我们的布局组件时,我们已经了解了如何加载 Stripe 并将我们的应用程序包装在 Elements 提供程序中,从而允许我们在任何使用此布局的页面中使用 Stripe Elements 组件。

在此示例中,我们使用默认的 PaymentIntents 集成,它将在客户端确认付款。因此,一旦用户提交表单,我们首先需要在 API 路由中创建一个 PaymentIntent :



// Partial of ./components/ElementsForm.tsx
// ...
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async e => {
    e.preventDefault();
    setPayment({ status: 'processing' });

    // Create a PaymentIntent with the specified amount.
    const response = await fetchPostJSON('/api/payment_intents', {
      amount: input.customDonation
    });
    setPayment(response);
// ...


Enter fullscreen mode Exit fullscreen mode


// Partial of ./pages/api/payment_intents/index.ts
// ...
// Validate the amount that was passed from the client.
if (!(amount >= MIN_AMOUNT && amount <= MAX_AMOUNT)) {
  throw new Error('Invalid amount.');
}
// Create PaymentIntent from body params.
const params: Stripe.PaymentIntentCreateParams = {
  payment_method_types: ['card'],
  amount: formatAmountForStripe(amount, CURRENCY),
  currency: CURRENCY,
};
const payment_intent: Stripe.PaymentIntent = await stripe.paymentIntents.create(
  params
);
// ...


Enter fullscreen mode Exit fullscreen mode

PaymentIntent 将提供一个client_secret接口,我们可以使用它在客户端使用 Stripe.js 完成付款。这使得 Stripe 能够自动处理额外的付款激活要求,例如使用 3D Secure 进行身份验证,这对于在欧洲和印度等地区接受付款至关重要。



// Partial of ./components/ElementsForm.tsx
// ...
 // Get a reference to a mounted CardElement. Elements knows how
    // to find your CardElement because there can only ever be one of
    // each type of element.
    const cardElement = elements!.getElement(CardElement);

    // Use the card Element to confirm the Payment.
    const { error, paymentIntent } = await stripe!.confirmCardPayment(
      response.client_secret,
      {
        payment_method: {
          card: cardElement!,
          billing_details: { name: input.cardholderName }
        }
      }
    );

    if (error) {
      setPayment({ status: 'error' });
      setErrorMessage(error.message ?? 'An unknown error occured');
    } else if (paymentIntent) {
      setPayment(paymentIntent);
    }
  };
// ...


Enter fullscreen mode Exit fullscreen mode

请注意,客户端确认付款意味着我们需要处理付款后事件。在本例中,我们将在下一步中实现一个 webhook 处理程序。

处理 Webhook 并检查其签名

Webhook 事件使我们能够自动收到 Stripe 帐户上发生的事件通知。这在使用异步支付、使用Stripe Billing进行订阅或使用Stripe Connect构建市场时尤其有用

默认情况下,Next.js API 路由仅支持同源。为了允许 Stripe webhook 事件请求到达我们的 API 路由,我们需要添加micro-cors



// Partial of ./pages/api/webhooks/index.ts
import Cors from 'micro-cors';

const cors = Cors({
  allowMethods: ['POST', 'HEAD'],
});
// ...
export default cors(webhookHandler as any);


Enter fullscreen mode Exit fullscreen mode

然而,这意味着现在任何人都可以向我们的 API 路由发送请求。为了确保 webhook 事件是由 Stripe 发送的,而不是恶意的第三方,我们需要验证 webhook 事件签名



// Partial of ./pages/api/webhooks/index.ts
// ...
const webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET!

// Stripe requires the raw body to construct the event.
export const config = {
  api: {
    bodyParser: false,
  },
}

const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST') {
    const buf = await buffer(req)
    const sig = req.headers['stripe-signature']!

    let event: Stripe.Event

    try {
      event = stripe.webhooks.constructEvent(buf.toString(), sig, webhookSecret)
    } catch (err) {
      // On error, log and return the error message
      console.log(`❌ Error message: ${err.message}`)
      res.status(400).send(`Webhook Error: ${err.message}`)
      return
    }

    // Successfully constructed event
    console.log('✅ Success:', event.id)
// ...


Enter fullscreen mode Exit fullscreen mode

这样,我们的 API 路由不仅能够接收来自 Stripe 的 POST 请求,还能确保只有 Stripe 发送的请求才会被实际处理。

使用 Vercel 将其部署到云端

您可以通过点击下面的“部署到 Vercel”按钮来部署此示例。它将指导您完成 secrets 设置并为您创建一个新的存储库:

部署到 Vercel

从那里,您可以将存储库克隆到本地计算机,并且每当您将更改提交/推送/合并到主服务器时,Vercel 都会自动为您重新部署该站点🥳

文章来源:https://dev.to/stripe/type-safe- payments-with-next-js-typescript-and-stripe-4jo7
PREV
借用检查器和内存管理介绍借用检查器
NEXT
在 Next.js 中开始使用 Markdoc