React × Stripe × Netlify Functionsでサーバーレスな決済基盤を持ったECサイトを作ってみる

## 概要

個人で開発しているサービスに決済機能を追加したく、どのサービスを使おうか迷っていたのですが、Stripe（[https://stripe.com/jp](https://stripe.com/jp) ）を使ってみたところ手軽にセキュアな決済機能を実装する事ができたのでメモ書き。

## 完成イメージ

![マイ-ムービー(5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/dc36ecc7-a046-e522-1898-d6078cae7d42.gif)

[https://simple-e-commerce-20210513.netlify.app](https://simple-e-commerce-20210513.netlify.app/)

## 使用技術

- React
- TypeScript
- Stripe
- NetlifyFunctions

## 事前知識

具体的な実装に入る前に、Stripeの仕様についていくつか押さえておかなければならない点があるので軽くまとめておきます。

### 3種類の組み込み方法

現在、Stripeには以下3種類の組み込み方法があり、それぞれ特徴が異なります。

- Stripe Checkout
- Charges API
- Payment Intents API

参照: https://stripe.com/docs/payments

#### Stripe Checkout

Stripeが用意した専用のサーバーに誘導して決済を行ってもらう形式。決済後は自サービスへリダイレクトさせる。

**イメージ**

![スクリーンショット 2021-05-13 23.41.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/e4143563-2cb8-347e-d8b0-db8fe524b647.png)

こんな感じでいかにもといった感じの良さげなページを少量の記述で準備する事ができます。

- メリット
  - 実装がシンプル（自前でサーバーを用意する必要が無く、処理の大部分をStripeに丸投げする事ができる）。
  - Stripeが提供している決済機能だと一目でわかるので利用者目線だと安心？（まだサービスが無名なうちとかは特に）
- デメリット
  - 後述の２種類に比べるとカスタマイズ性はやや劣る。

参照: https://stripe.com/docs/payments/checkout

#### Charges API & Payment Intents API

自前でサーバーを用意し、自サービス内で決済を完了させる形式。

**イメージ**

![スクリーンショット 2021-05-14 0.02.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/c299bf36-221b-aa3b-74d8-2c3edaf5dc0b.png)


Stripe Checkoutとは打って変わりUIや決済処理をほぼ全て自前で実装していく事になるため、カスタマイズ性という面では融通が効きそうです。

今回はサンプルアプリなのでだいぶ適当な感じですが、Stripeが提供している「[Stripe Elements](https://stripe.com/jp/payments/elements)」を上手く組み合わせる事で多様な決済画面が作れると思います。

- メリット
  - そのほとんどを自前で実装する事になるため、カスタマイズ性に優れる。
- デメリット
  - 実装コストが高い（前述のようにクライアントだけでなくサーバーの実装も必要になるため）。

なお、「Charges API」と「Payment Intentes」の主な違いについては以下を確認してください。

参照: https://stripe.com/docs/payments/payment-intents/migration/charges
参照: https://stripe.com/docs/payments/charges-api

ざっくり言うと「Charges API」が旧式、「Payment Intentes API」が新式に該当するようです。

セキュリティ面などで後者の方が優れるため、できるだけ後者を使った方が良いように思えますが、より詳細な部分を見ていくと使用可能な決済方法が微妙に違うなどそれぞれ一長一短もあるようです。（つまり適切な使い分けがベター？）

もっとも、公式の見解によると今後新機能が追加される場合は「Payment Intents API」のみが対象となるようなので、今回の記事ではこちらを使用した実装で進めていきます。

参照: https://stripe.com/docs/payments/payment-intents/migration/charges

## 実装

前置きはほどほどに実装していきましょう。

### 各種APIキーを準備

![スクリーンショット 2021-05-14 0.25.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/53797874-e10d-68dd-d56a-63e677219288.png)

[https://stripe.com/jp](https://stripe.com/jp)

まだStripeのアカウントを持っていない場合はササっと作成してください。

![スクリーンショット 2021-05-14 0.27.36_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f7de7fbb-f37d-5a2c-27d6-7084189b636f.jpeg)

ダッシュボードから「開発者」→「APIキー」と進み、「公開可能キー」と「シークレットキー」の2つをメモに控えておきましょう。

なお、今回はテスト環境用のAPIキーを使用してきます。

### クライアント

次にクライアント側の実装に入ります。

#### create-react-app

おなじみのコマンドで雛形を作成。

```
$ npx create-react-app simple-e-commerce --template typescript
$ cd simple-e-commerce
```

#### 不要なファイルを削除

この先使う事の無いファイルを削除しておきます。

```
$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts
```

それに伴い、以下の2ファイルを編集。

```tsx:./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
```

```tsx:./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App
```

#### 動作確認

```
$ yarn start
```

![スクリーンショット 2021-05-14 0.37.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/e8fdc8fc-f11a-1dbf-ea0e-e94e21588cef.png)

http://localhost:3000 にアクセスして「Hello World!」が表示されていればOKです。

#### 各種ライブラリをインストール

この先使用する事になるライブラリをまとめてインストールしてしまいます。

```
$ yarn add @material-ui/core @material-ui/icons @stripe/react-stripe-js @stripe/stripe-js stripe dotenv-webpack http-proxy-middleware netlify-lambda 
$ yarn add -D @types/stripe npm-run-all
```

- material-ui関連
  - UIを整えるために使用。
- stripe関連
  - Stripeを利用するために使用。
- dotenv-webpack
  - 環境変数を取り扱うために使用。
- http-proxy-middleware
  - CORS対策のために使用。
- netlify-lambda
  - Netlify Functionsを利用するために使用。
- npm-run-all
  - npm-scriptsを一気に実行するために使用。

#### 各種ディレクトリ・ファイルを作成

この先使用する事になるディレクトリ・ファイルをまとめて作成してしまいます。

```
$ cat > provisioning.sh << EOS
#!/bin/bash

mkdir src/components
mkdir src/components/cart
mkdir src/components/checkout
mkdir src/components/layouts
mkdir src/components/product

mkdir src/data
mkdir src/interfaces
mkdir src/lambda

touch src/components/cart/Cart.tsx
touch src/components/cart/CartItem.tsx
touch src/components/checkout/CheckoutForm.tsx
touch src/components/layouts/Header.tsx
touch src/components/product/Product.tsx

touch src/data/products.ts
touch src/interfaces/index.ts
touch src/lambda/paymentIntents.js

touch src/setupProxy.js
touch src/setupProxy.ts

touch .env
touch netlify.toml
touch webpack.functions.js
EOS
```

たくさんあって面倒なのでシェルスクリプトを作成して実行してください。

```
$ sh provisioning.sh
```

最終的に次のような構成になっていればOKです。

```
SIMPLE-E-COMMERCE
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── components
│   │   ├── cart
│   │   │   ├── Cart.tsx
│   │   │   └── CartItem.tsx
│   │   ├── checkout
│   │   │   └── CheckoutForm.tsx
│   │   ├── layouts
│   │   │   └── Header.tsx
│   │   └── product
│   │       └── Product.tsx
│   ├── data
│   │   └── products.ts
│   ├── lambda
│   │   └── paymentIntents.js
│   ├── interfaces
│   │   └── index.ts
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── react-app-env.d.ts
│   ├── setupProxy.js
│   └── setupProxy.ts
├── .env
├── .gitignore
├── netlify.toml
├── package.json
├── provisioning.sh
├── README.md
├── tsconfig.json
├── webpack.functions.js
└── yarn.lock
```

#### 型を定義

まず最初にアプリ全体で使い回す型を定義しておきます。

```ts:./src/interfaces/index.ts
export interface Product {
  id: number
  title: string
  description: string
  price: number
  image: string
}

export interface CartItem {
  id?: number | undefined
  title?: string | undefined
  price?: number | undefined
  quantity: number
  cost?: number | undefined
}
```

- 商品（Product）
  - title: 商品名
  - description: 商品説明
  - price: 商品価格
  - image: 画像
- カート内（CartItem）
  - title: 商品名
  - price: 商品価格
  - quantity: 数量
  - cost: 合計金額（price × quantity）

#### 商品データを作成

本来であればAPI（microCMSとか使うと良いかも？）などで別途管理すべき項目かもしれませんが、今回は単純化のために適当にJSONデータで保持しておきます。

```ts:./src/data/product.ts
import { Product } from "../interfaces/index"

export const products: Product[] = [
  {
    id: 1,
    title: "Banana",
    description: "Lorem ipsum dolor sit amet.",
    price: 100,
    image: "https://food-foto.jp/free/img/images_big/fd400883.jpg"
  },
  {
    id: 2,
    title: "Apple",
    description: "Lorem ipsum dolor sit amet.",
    price: 200,
    image: "https://food-foto.jp/free/img/images_big/fd400993.jpg"
  },
  {
    id: 3,
    title: "Orange",
    description: "Lorem ipsum dolor sit amet.",
    price: 300,
    image: "https://food-foto.jp/free/img/images_big/fd401266.jpg"
  }
]
```

#### 各種ビューを作成

見た目の部分を作り込んでいきます。

##### index

```css:./src/index.css
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

.StripeElement {
  display: block;
  margin: 0.5rem auto 1.5rem;
  max-width: 500px;
  padding: 12px 16px;
  font-size: 1rem;
  border: 1px solid #eee;
  border-radius: 3px;
  outline: 0;
  background: white;
}
```

##### App

```tsx:./src/App.tsx
import React, { useState } from "react"

import { Elements } from "@stripe/react-stripe-js"
import { loadStripe } from "@stripe/stripe-js"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import { products } from "./data/products"
import Product from "./components/product/Product"
import Cart from "./components/cart/Cart"
import CheckoutForm from "./components/checkout/CheckoutForm"

import Header from "./components/layouts/Header"

import { CartItem as CartItemType } from "./interfaces/index"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "3rem"
  }
}))

const App: React.FC = () => {
  const classes = useStyles()

  const [cartItems, setCartItems] = useState<CartItemType[]>([])

  const handleAddToCart = (id: number) => {
    setCartItems((cartItems: CartItemType[]) => {
      const cartItem = cartItems.find((item) => item.id === id)

      // すでに同じ商品が入っている場合は数量を増加
      if (cartItem) {
        return cartItems.map((item) => {
          if (item.id !== id) return item
          return { ...cartItem, quantity: item.quantity + 1 }
        })
      }

      // そうでない場合は新たに追加
      const newCartItem = products.find(item => item.id === id)
      return [...cartItems, { ...newCartItem, quantity: 1 }]
    })
  }

  // 合計金額を算出
  const totalCost = cartItems.reduce(
    (acc: number, item: CartItemType) => acc + (item.price || 0) * item.quantity,
    0
  )

  const stripePublicKey = process.env.REACT_APP_STRIPE_PUBLIC_KEY || "" // Stripe APIの公開鍵
  const stripePromise = loadStripe(stripePublicKey)

  return (
    <>
      <header>
        <Header />
      </header>
      <main>
        <Container maxWidth="lg">
          <Grid container spacing={4} justify="center" className={classes.container}>
            {products.map(product => (
              <Grid item key={product.id}>
                <Product
                  title={product.title}
                  description={product.description}
                  price={product.price}
                  image={product.image}
                  handleAddToCart={() => handleAddToCart(product.id)}
                />
              </Grid>
            ))}
          </Grid>
          <Cart
            cartItems={cartItems}
            totalCost={totalCost}
            setCartItems={setCartItems}
          />
          { cartItems.length > 0 && (
            <Elements stripe={stripePromise}>
              <CheckoutForm totalCost={totalCost} />
            </Elements>
          )}
        </Container>
      </main>
    </>
  )
}

export default App
```

##### header

```tsx:./src/components/layouts/Header.tsx
import React from "react"
import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import InputBase from "@material-ui/core/InputBase"
import { createStyles, fade, Theme, makeStyles } from "@material-ui/core/styles"
import MenuIcon from "@material-ui/icons/Menu"
import SearchIcon from "@material-ui/icons/Search"

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    header: {
      flexGrow: 1
    },
    menuButton: {
      marginRight: theme.spacing(2)
    },
    title: {
      flexGrow: 1,
      display: "none",
      [theme.breakpoints.up("sm")]: {
        display: "block"
      }
    },
    search: {
      position: "relative",
      borderRadius: theme.shape.borderRadius,
      backgroundColor: fade(theme.palette.common.white, 0.15),
      "&:hover": {
        backgroundColor: fade(theme.palette.common.white, 0.25),
      },
      marginLeft: 0,
      width: "100%",
      [theme.breakpoints.up("sm")]: {
        marginLeft: theme.spacing(1),
        width: "auto"
      }
    },
    searchIcon: {
      padding: theme.spacing(0, 2),
      height: "100%",
      position: "absolute",
      pointerEvents: "none",
      display: "flex",
      alignItems: "center",
      justifyContent: "center"
    },
    inputRoot: {
      color: "inherit"
    },
    inputInput: {
      padding: theme.spacing(1, 1, 1, 0),
      paddingLeft: `calc(1em + ${theme.spacing(4)}px)`,
      transition: theme.transitions.create("width"),
      width: "100%",
      [theme.breakpoints.up("sm")]: {
        width: "12ch",
        "&:focus": {
          width: "20ch"
        }
      }
    }
  })
)

const Header: React.FC = () => {
  const classes = useStyles()

  return (
    <div className={classes.header}>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            edge="start"
            className={classes.menuButton}
            color="inherit"
            aria-label="open drawer"
          >
            <MenuIcon />
          </IconButton>
          <Typography className={classes.title} variant="h6" noWrap>
            Sample Shop
          </Typography>
          <div className={classes.search}>
            <div className={classes.searchIcon}>
              <SearchIcon />
            </div>
            <InputBase
              placeholder="Search…"
              classes={{
                root: classes.inputRoot,
                input: classes.inputInput,
              }}
              inputProps={{ "aria-label": "search" }}
            />
          </div>
        </Toolbar>
      </AppBar>
    </div>
  )
}

export default Header
```


##### product

```tsx:./src/components/product/Product.tsx
import React from "react"

import { makeStyles } from "@material-ui/core/styles"
import Card from "@material-ui/core/Card"
import CardHeader from "@material-ui/core/CardHeader"
import CardMedia from "@material-ui/core/CardMedia"
import CardContent from "@material-ui/core/CardContent"
import Typography from "@material-ui/core/Typography"
import ShoppingCartOutlinedIcon from "@material-ui/icons/ShoppingCartOutlined"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"

const useStyles = makeStyles(() => ({
  card: {
    minWidth: 300,
    margin: "1rem",
    transition: "all 0.3s",
    "&:hover": {
      boxShadow:
        "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
      transform: "translateY(-3px)"
    }
  },
  cardHeader: {
    textAlign: "center"
  },
  cardMedia: {
    height: 0,
    paddingTop: "56.25%"
  },
  box: {
    padding: "0 1rem 1rem"
  },
  cartBtn: {
    textTransform: "none"
  }
}))

interface ProductProps {
  handleAddToCart: React.MouseEventHandler<HTMLButtonElement> | undefined
  price: number
  title: string
  description: string
  image: string
}

const Product: React.FC<ProductProps> = ({ handleAddToCart, price, title, description, image }) => {
  const classes = useStyles()

  return (
    <Card className={classes.card}>
      <CardHeader
        title={title}
        className={classes.cardHeader}
      />
      <CardMedia className={classes.cardMedia} image={image} title={title} />
      <CardContent>
        <Typography variant="body1" color="inherit" component="p" align="center" gutterBottom>
          {description}
        </Typography>
        <Typography variant="body2" color="textSecondary" component="p" align="center">
          ¥{price}
        </Typography>
      </CardContent>
      <Box className={classes.box}>
        <Button
          variant="outlined"
          color="primary"
          startIcon={<ShoppingCartOutlinedIcon />}
          className={classes.cartBtn}
          fullWidth
          onClick={handleAddToCart}
        >
          Add to cart
        </Button>
      </Box>
    </Card>
  )
}

export default Product
```

##### cart

```tsx:./src/components/cart/Cart.tsx
import React from "react"
import CartItem from "./CartItem"

import { makeStyles } from "@material-ui/core/styles"
import { Container } from "@material-ui/core"
import Button from "@material-ui/core/Button"
import ClearOutlinedIcon from "@material-ui/icons/ClearOutlined"

import { CartItem as CartItemType } from "../../interfaces/index"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "3rem",
    maxWidth: 800,
    textAlign: "center"
  },
  resetBtn: {
    textTransform: "none"
  }
}))

interface CartProps {
  cartItems: CartItemType[]
  totalCost: number
  setCartItems: Function
}

const Cart: React.FC<CartProps> = ({ cartItems, totalCost, setCartItems }) => {
  const classes = useStyles()

  // カート内の商品をクリア
  const handleResetCart = () => {
    setCartItems([])
  }

  return (
    <>
      <Container className={classes.container}>
        <h2>Your shopping cart</h2>
        { cartItems.length > 0 ? (
          <>
            {cartItems.map((cartItem: CartItemType) => (
              <CartItem
                key={cartItem.id}
                id={cartItem.id}
                title={cartItem.title}
                cost={(cartItem.price || 0) * cartItem.quantity}
                quantity={cartItem.quantity}
              />
            ))}
            <h4>Total cost: ¥{totalCost.toFixed(2)}</h4>
            <Button
              type="submit"
              variant="outlined"
              color="secondary"
              startIcon={<ClearOutlinedIcon />}
              className={classes.resetBtn}
              onClick={handleResetCart}
            >
              Clear cart
            </Button>
          </>
        ) : (
          <p>Empty</p>
        )}
      </Container>
    </>
  )
}

export default Cart
```


```tsx:./src/components/cart/CartItem.tsx
import React from "react"

import { makeStyles } from "@material-ui/core/styles"
import Grid from "@material-ui/core/Grid"
import Divider from "@material-ui/core/Divider"

const useStyles = makeStyles(() => ({
  container: {
    flexGrow: 1,
    marginTop: 0
  },
  item: {
    textAlign: "center",
    display: "table-cell",
    verticalAlign: "middle"
  },
  divider: {
    marginTop: "0.5rem"
  }
}))

interface CartItemProps {
  id: number | undefined
  title: string | undefined
  quantity: number
  cost: number | undefined
}

const CartItem: React.FC<CartItemProps> = ({ title, quantity, cost }) => {
  const classes = useStyles()

  return (
    <>
      <Grid container spacing={3} justify="center" className={classes.container}>
        <Grid item xs={4} className={classes.item}>
          {title}
        </Grid>
        <Grid item xs={4} className={classes.item}>
          {quantity}
        </Grid>
        <Grid item xs={4} className={classes.item}>
          ¥{cost}
        </Grid>
      </Grid>
      <Divider className={classes.divider} />
    </>
  )
}

export default CartItem
```

##### checkout

```tsx:./src/components/checkout/CheckoutForm.tsx
import React, { useState } from "react"

import { CardElement, useStripe, useElements, } from "@stripe/react-stripe-js"

import { makeStyles } from "@material-ui/core/styles"
import { Container } from "@material-ui/core"
import Button from "@material-ui/core/Button"
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp"

import Dialog from "@material-ui/core/Dialog"
import DialogActions from "@material-ui/core/DialogActions"
import DialogContent from "@material-ui/core/DialogContent"
import DialogContentText from "@material-ui/core/DialogContentText"
import DialogTitle from "@material-ui/core/DialogTitle"
import Slide from "@material-ui/core/Slide"
import { TransitionProps } from "@material-ui/core/transitions"

const Transition = React.forwardRef(function Transition(
  props: TransitionProps & { children?: React.ReactElement<any, any> },
  ref: React.Ref<unknown>,
) {
  return <Slide direction="up" ref={ref} {...props} />
})

const useStyles = makeStyles(() => ({
  container: {
    margin: "5rem 0 3rem",
    textAlign: "center"
  },
  agreeBtn: {
    textTransform: "none"
  },
  submitBtn: {
    textTransform: "none"
  }
}))

interface CompletionDialogProps {
  open: boolean
  title: string
  text: string
  handleClose: VoidFunction
}

// 決済処理後に表示するダイアログ（成功時も失敗時も）
const CompletionDialog = ({ open, title, text, handleClose }: CompletionDialogProps) => {
  const classes = useStyles()

  return (
    <div>
      <Dialog
        open={open}
        TransitionComponent={Transition}
        keepMounted
        onClose={handleClose}
      >
        <DialogTitle>
          {title}
        </DialogTitle>
        <DialogContent>
          <DialogContentText>
            {text}
          </DialogContentText>
        </DialogContent>
        <DialogActions>
          <Button
            color="primary"
            onClick={handleClose}
            className={classes.agreeBtn}
          >
            Agree
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  )
}

interface CheckoutFormProps {
  totalCost: number
}

const CheckoutForm: React.FC<CheckoutFormProps> = ({ totalCost}) => {
  const classes = useStyles()

  const [status, setStatus] = useState<"default" | "submitting" | "succeeded" | "failed">("default")
  const [open, setOpen] = useState<boolean>(false)

  const handleOpen = () => {
    setOpen(true)
  }

  const handleClose = () => {
    setOpen(false)
  }

  const stripe = useStripe()
  const elements = useElements()

  // Stripeの決済方法はCharges APIとPayment Intents APIの２種類があるのでどちらかを選択
  // Charges: シンプルな一方、SCA対応していないためセキュリティ度はやや低い
  // Payment Intents: Chargesに比べて支払いまでのプロセスが増える一方、SCA対応しているためセキュリティ度は高い
  // 参照: https://stripe.com/docs/payments/payment-intents/migration/charges#understanding-the-stripe-payment-apis
  // 必ずしもどちらが良いかというわけではなく上手く使い分けるのが望ましいらしいが、今後の新機能はPayment Intents APIのみに追加されるとの事

  // なので今回はPayment Intents API方式で実装
  const handleSubmit = async (e: any) => {
    e.preventDefault()

    if (!stripe || !elements) return

    setStatus("submitting")

    try {
      const res = await fetch("/.netlify/functions/paymentIntents", {
        method: "POST",
        body: JSON.stringify({
          amount: totalCost
        }),
        headers: { "Content-Type": "application/json" }
      })

      const data = await res.json()
      const client_secret = data.client_secret // レスポンス内からclient_secretを取得

      const card = elements?.getElement(CardElement) || { "token": ""} // クレジットカード情報を取得

      // 決済処理
      const result = await stripe?.confirmCardPayment(client_secret, {
        payment_method: {
          card: card,
          billing_details: {
            name: "Test User"
            // 他にもaddress（住所）、email（メールアドレス）、phone（電話番号）などが付与可能
          }
        }
      })

      if (result?.paymentIntent?.status === "succeeded") {
        setStatus("succeeded")
      } else {
        throw new Error("Network response was not ok.")
      }
    } catch (err) {
      setStatus("failed")
    }

    handleOpen()
  }

  return (
    <Container className={classes.container}>
      <form onSubmit={handleSubmit}>
        <h4>Would you like to complete the purchase?</h4>
        <CardElement />
        <Button
          type="submit"
          variant="outlined"
          disabled={status === "submitting"} // submitting中は再度ボタンを押せないように
          startIcon={<KeyboardArrowUpIcon />}
          className={classes.submitBtn}
        >
          {status === "submitting" ? "Submitting" : "Submit"}
        </Button>
      </form>
      <CompletionDialog
        open={open}
        title={status === "succeeded" ? "Succeeded!" : "Failed"}
        text={status === "succeeded" ? "Thank you, your payment was successful!" : "Sorry, something went wrong. Please check your credit card information again."}
        handleClose={handleClose}
      />
    </Container>
  )
}

export default CheckoutForm
```

### サーバー

最後にサーバー側の実装に入ります。

#### lambda関数を作成

```js:./src/lambda/paymentIntents.js
import Stripe from "stripe"

const secretKey = process.env.STRIPE_SECRET_KEY
const stripe = new Stripe(secretKey, { apiVersion: "2020-08-27" })

exports.handler = async (event, context, callback) => {
  // POSTメソッド以外は拒否
  if (event.httpMethod !== "POST") {
    return callback(null, { statusCode: 405, body: "Method Not Allowed" })
  }

  const data = JSON.parse(event.body)

  // 1円に満たない金額だった場合はエラー
  if (parseInt(data.amount) < 1) {
    return callback(null, {
      statusCode: 400,
      body: JSON.stringify({
        message: "Some required fields were not supplied."
      })
    })
  }

  await stripe.paymentIntents.create({
    amount: parseInt(data.amount),
    currency: "jpy",
    description: "Sample Shop",
    metadata: { integration_check: "accept_a_payment" }
  })
  .then(({ client_secret }) => {
    return callback(null, {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type"
      },
      body: JSON.stringify({
        client_secret: client_secret // 取引を確認するためのclient_secretを返す
      })
    })
  })
  .catch((err) => {
    return callback(null, {
      statusCode: 400,
      body: JSON.stringify({
        message: `Error: ${err.message}`
      })
    })
  })
}
```

#### 環境変数をセット

```./webpack.functions.js
const Dotenv = require("dotenv-webpack")

module.exports = {
  plugins: [new Dotenv()]
}
```

```./.env
REACT_APP_STRIPE_PUBLIC_KEY=<Stripeの公開可能キー>
STRIPE_SECRET_KEY=<Stripeのシークレットキー>
```

#### プロキシの設定

「/.netlify/functions/」へのリクエストを「[http://localhost:9000](http://localhost:9000)」へ代替し、CORSを有効にします。

```js:./setupProxy.js
const { createProxyMiddleware } = require("http-proxy-middleware")

module.exports = function(app) {
  app.use(
    "/.netlify/functions/",
    createProxyMiddleware({
      target: "http://localhost:9000",
      changeOrigin: true
    })
  )
}
```

```ts:./setupProxy.ts
import { createProxyMiddleware } from "http-proxy-middleware"

module.exports = function(app: any) {
  app.use(
    "/.netlify/functions/",
    createProxyMiddleware({
      target: "http://localhost:9000",
      changeOrigin: true
    })
  )
}
```

#### Netfily Functionsの設定

Netlify Functionsに関しては下記の記事が参考になるので読んでみてください。
参照: https://qiita.com/Sr_Bangs/items/7867853f5e71bd4ada56

```toml:./netlify.toml
[build]
  Command = "npm run build"
  Functions = "lambda"
  Publish = "build"
```

### 動作確認

クライアント、サーバーともに準備ができたのでいよいよ動作確認。

#### npm-scriptsを編集

「./package.json」内の「scripts」を次のように書き換えます。

```json:./package.json
"scripts": {
    "start": "run-p start:**",
    "start:app": "react-scripts start",
    "start:lambda": "netlify-lambda serve src/lambda --config ./webpack.functions.js",
    "build": "run-p build:**",
    "build:app": "react-scripts build",
    "build:lambda": "netlify-lambda build src/lambda",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
```

これをやっておくと、今後は

```
$ npm run start
```

を一発叩くだけでクライアント（localhost:3000）とサーバー（localhost:9000）が同時に立ち上がるようになるので便利です。

![マイ-ムービー(5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/dc36ecc7-a046-e522-1898-d6078cae7d42.gif)

テスト環境の場合、以下のダミークレジットカード情報が使用可能なので色々試してみてください。

**Visa**

- カード番号: 4242424242424242
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字５桁

**Mastercard**

- カード番号: 5555555555554444
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字５桁

**JCB**

- カード番号: 3566002020360505
- セキュリティコード: 任意の数字3桁
- 日付: 将来の日付であれば何でもOK
- 郵便番号: 任意の数字５桁

無事、「Succeeded!」というダイアログが表示されれば成功です。

今回は簡潔化のためにダイアログを表示するだけに留めていますが、実際の運用においては結果に合わせてページを遷移するなどの工夫を加えてみると良いかもしれません。

![スクリーンショット 2021-05-14 1.56.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/949bbea7-839a-f4a0-064a-e4437be5f499.png)

念のため、Stripeダッシュボードの「支払い」から確認しておきましょう。

## デプロイ 

![スクリーンショット 2021-05-14 1.58.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/e0b72856-2ba4-0de0-4bde-460dddb7feab.png)

[https://www.netlify.com](https://www.netlify.com/)

Netlifyのアカウントをお持ちでない場合は作成しておいてください。（GitHubアカウントでの作成を推奨。）

![スクリーンショット 2021-05-14 2.00.09_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6e8b4f6c-709b-c2c6-f7e4-d1b37c9dfe1d.jpeg)

ログインしてダッシュボードに入ると、「New site from Git」というボタンがあるのでクリック。

![スクリーンショット 2021-05-14 2.02.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/0dd67b35-dbd4-cef8-53ce-065a8f806b3a.png)

するとデプロイのためのリポジトリを選択する画面になるので、該当のリポジトリを選択しましょう。（事前に先ほど作成したコードをGitHubにアップロードしておいてください。）

![FireShot Capture 142 - Create a new site - Netlify - app.netlify.com_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f55e3679-f1b4-3796-a41e-3b4af93ccf28.jpeg)

あとはデプロイ時の設定を上記画像のように行い、「Deploy site」をクリックすればOKです。数分程度でビルド〜デプロイまで完了するはず。

## あとがき

以上、簡易的なものではありますが、ECサイトもどきを作ってみました。

サーバー有りでStripeを利用しようと思った場合、Netlify Functionsを使えば費用を大幅に抑える事ができそうです。ちょっとしたペラページで運用したい場合などは試してみてはいかがでしょうか。

今回作成したコード: https://github.com/kazama1209/simple-e-commerce