LoginSignup
25
40

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-05-13

概要

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

完成イメージ

マイ-ムービー(5).gif

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

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

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

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

Charges API & Payment Intents API

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

イメージ

スクリーンショット 2021-05-14 0.02.15.png

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

今回はサンプルアプリなのでだいぶ適当な感じですが、Stripeが提供している「Stripe 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://stripe.com/jp

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

スクリーンショット 2021-05-14 0.27.36_censored.jpg

ダッシュボードから「開発者」→「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ファイルを編集。

./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")
)
./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

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

型を定義

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

./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データで保持しておきます。

./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
./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
./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
./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
./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
./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
./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
./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の2種類があるのでどちらかを選択
  // 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関数を作成

./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」へ代替し、CORSを有効にします。

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

module.exports = function(app) {
  app.use(
    "/.netlify/functions/",
    createProxyMiddleware({
      target: "http://localhost:9000",
      changeOrigin: true
    })
  )
}
./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

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

動作確認

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

npm-scriptsを編集

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

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

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

Visa

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

Mastercard

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

JCB

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

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

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

スクリーンショット 2021-05-14 1.56.03.png

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

デプロイ

スクリーンショット 2021-05-14 1.58.14.png

https://www.netlify.com

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

スクリーンショット 2021-05-14 2.00.09_censored.jpg

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

スクリーンショット 2021-05-14 2.02.13.png

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

FireShot Capture 142 - Create a new site - Netlify - app.netlify.com_censored.jpg

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

あとがき

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

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

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

25
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
40