概要
バックエンド:SpringBoot フロントエンドNext.jsで決済機能を実装します。
以下の記事の内容をまとめて、クレジットカードの決済機能を追加します。
バック:SpringBoot RestApiに決済機能実装 Stripeの支払いインテントを作るまで 1/4
フロント:Reactで決済機能を実装する Stripeの支払いを完了するまで 2/4
バック:SpringBoot で決済機能を実装する。Stripeでの決済結果をローカルのデータベース上に記録する 3/4
今回追加する実装部分としては、フロントエンド側の実装がメインになります。
具体的には、今まで作ったバックエンドのAPI StripeのAPIなりを叩いて、決済を完了するところまで実装します。
開発環境
IDE:VScode
├── @babel/core@7.20.7
├── @storybook/addon-actions@6.5.15
├── @storybook/addon-essentials@6.5.15
├── @storybook/addon-interactions@6.5.15
├── @storybook/addon-links@6.5.15
├── @storybook/addon-postcss@2.0.0
├── @storybook/builder-webpack4@6.5.15
├── @storybook/manager-webpack4@6.5.15
├── @storybook/react@6.5.15
├── @storybook/testing-library@0.0.13
├── @stripe/react-stripe-js@1.9.0
├── @stripe/stripe-js@1.32.0
├── @styled-jsx/plugin-sass@3.0.0
├── @styled/storybook-addon-jsx@7.4.2
├── @types/node@18.11.15
├── @types/react-dom@18.0.9
├── @types/react@18.0.26
├── axios@1.2.1
├── babel-loader@8.3.0
├── babel-plugin-react-require@3.1.3
├── eslint-config-next@13.0.6
├── eslint-plugin-storybook@0.6.8
├── eslint@8.29.0
├── msw@0.49.3
├── next-auth@4.18.7
├── next@13.0.1
├── react-bootstrap@2.7.0
├── react-dom@18.2.0
├── react@18.2.0
├── sass@1.57.1
├── stripe@11.12.0
├── styled-components@5.3.6
├── styled-jsx@5.1.1
└── typescript@4.9.5
図
実装したい機能
買い物カゴに入っている商品をクレカ決済する機能を実装します。
クレカ決済はStripeを使います。
決済の流れ
下図の赤枠部分を実装します。
青枠で囲まれた部分は過去の部分で実装した部分です。
実装
作成したファイル
ファイル名 | 説明 |
---|---|
pages/develop/checkOutForm/index.tsx | メイン処理部分 |
customerService.ts | 認証のAPIが入っているファイル |
cartItemServiceV2.ts | ユーザーの買い物カゴ情報を取得するAPIの処理が入っているファイル |
orderService.ts | 決済系のAPIの処理が入っているファイル |
CartitemResponse.d.ts | 型定義ファイル。買い物カゴ情報を取得したときのレスポンスを定義しているファイル |
CartItemDto.d.ts | 型定義ファイル。CartItemResponseで使われている。 |
コード部分(長いので作成したファイル欄)
index.tsx
import { loadStripe, StripeElementsOptions } from "@stripe/stripe-js";
import { useEffect, useState } from "react";
import { CartItemResponse } from "../../../types/cartItem/cartItemResponse";
import { Customer } from "../../../types/customer";
import * as CustomerService from "../../../service/customerService";
import * as CartItemService2 from "../../../service/cartItemServiceV2";
import * as OrderService from "../../../service/orderService";
import { createPaymentIntent } from "../../../service/orderService";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
// Stripeの公開キーを読み込む
const stripePromise = loadStripe(
"pk_test_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
);
// http://127.0.0.1:3000/develop/checkOutForm
// 決済画面検証用ページ
// 買い物カゴのの中を表示して、Stripeクレジットカード決済するページです。
// 決済完了後は自前のmySQLにデータを保存します。
export default function Index() {
///↓↓↓買い物カゴ情報を取得する機能↓↓↓///
// cartItemの情報をjsx上に表示するときに使う変数
const [cartItemsResponse, setCartItemsResponse] =
useState<CartItemResponse>();
// clientSecretを格納する変数
// クレカ入力欄を構築するためにStateにしている。
const [clientSecret, setClientSecret] = useState<string>("");
// 支払い総額,createIntent関数を実行するときに使う時に利用。
// cartItemsResponse?.totalで渡そうとすると undefinedとなるためこの変数を作成しました。
let tempTotal: any;
// 支払い画面を作るために必要な情報を格納する変数
let options = {
appearance: {
theme: "stripe",
},
clientSecret: clientSecret,
};
useEffect(() => {
loginAndGetCartItem();
}, []);
// ログイン情報の定義
const loginRequestBody: Customer = {
email: "aaaba@gmail.com",
password: "test",
append: function (arg0: string, inputEmail: string): unknown {
throw new Error("Function not implemented.");
},
};
// useEffectで最初に実行する関数
// ログインしてユーザーの買い物カゴ情報を取得し、クレカの支払い情報を作成する。
const loginAndGetCartItem = async () => {
// ログイン部分
const response = await CustomerService.signIn2(loginRequestBody);
localStorage.setItem("accessToken", response);
await getCartItem();
await createPaymentIntent(tempTotal);
console.log(options);
};
// ユーザーのカート情報を取得する loginAndGetCartitemで実行する。
const getCartItem = async () => {
const response: CartItemResponse = await CartItemService2.getCartItems2(
localStorage.getItem("accessToken") as unknown as string
);
setCartItemsResponse(response);
tempTotal = response.total.toFixed();
};
///↑↑↑買い物カゴ情報を取得する機能↑↑↑///
//PaymentIntentを作成する(PaymentIntentのキーが無いとクレカの入力フォームが作れないため)loginAndGetCartitemで実行する。
const createPaymentIntent = async (total: number) => {
//1.バックエンドのSpringBoot側のAPIを実行しPaymentIntentを作成する。バックエンド側でStripeの支払い情報を作成します。client_secretKeyをレスポンスから取得する
let response: any = await OrderService.createPaymentIntent(
total,
"jpy",
"test@gtest"
);
//2.PaymentIntentのclient_secretKeyを引数に渡す。
setClientSecret(response?.client_secret);
options.clientSecret = response?.client_secret;
}
return (
<div>
<div>決済画面</div>
<div>買い物カゴ情報</div>
<div>クレジットカード情報</div>
<div>
{cartItemsResponse?.cartItemDtos.map((cartItem) => {
return (
<div key={cartItem.id} className="CartItemContent">
<div>商品名:{cartItem.productName}</div>
<div>価格:{cartItem.priceWithTax}</div>
<div>数量:{cartItem.quantity}</div>
<div>
小計:{(cartItem.priceWithTax * cartItem.quantity).toFixed()}
円
</div>
</div>
);
})}
<div>送料{cartItemsResponse?.shippingCost}円</div>
<div>お客様が支払う額:{cartItemsResponse?.total.toFixed()}円</div>
</div>
{/*↓↓ クレカ入力画面 ↓↓*/}
{options.clientSecret == "" ? (
<div></div>
) : (
<div className="CardWidth">
<Elements stripe={stripePromise} options={options}>
<Payment />
</Elements>
</div>
)}
<style jsx>{`
// クレジットカード情報の入力欄の幅を調整するために作成
.CartItemContent {
border: 1px solid black;
width: 300px;
}
.CardWidth {
width: 300px;
}
`}</style>
</div>
);
};
// クレジット入力欄のコンポーネント
const Payment = () => {
const stripe = useStripe();
const elements = useElements();
// completePayment実行時にlocalStorageの値が入っていないから
// TypeScript上の変数にaccessTokenを渡しておく。
// localStorageのaccessTokenの値が消滅する原因はまだ不明?
// ページ遷移後にlocalStrorageの値が消滅する仕様?(あくまで憶測で検証もしてないので あくまで憶測)
const jwt: string | null = localStorage.getItem("accessToken");
return (
<form
// 決済ボタンを押したときの処理
onSubmit={(e) => {
e.preventDefault();
if (!stripe || !elements) return;
// Stripe側のAPIを実行し、支払い情報を完了する。
stripe
.confirmPayment({
elements,
confirmParams: {
return_url: "http://localhost:3000",
},
})
// SpringBoot側のAPIを実行し、自前のデータベースの情報を更新します。
// todo 成功失敗問わずに実行するので、例外処理を加える必要がある
OrderService.completePayment(jwt as string);
}}
>
{/* クレカ情報の入力フォーム Stripeのpublic_keyとPaymentIntentのclientSecretが必要 */}
<PaymentElement />
<button type="submit">Submit</button>
</form>
);
};
customerService.ts
import axios from 'axios';
import { URLSearchParams } from 'url';
import { Customer } from '../types/customer';
import { response } from 'msw';
export function signIn2({email,password}: Customer):Promise<string>{
return axios.post(`http://127.0.0.1:5000/api/auth/signin`,
{ email: email, password: password },
{
headers: {
'Request-Method': 'POST',
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'http://127.0.0.1:5000/*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Credentials': 'true'
}
} //COR回避
).then((response : any)=>{
console.log(response)
return response.data.accessToken;
})
.catch((error)=>{
return error;
});
};
export const register= async (inputEmail:string,inputPassword:string) =>{
return await axios.post(`http://127.0.0.1:5000/api/auth/signup`,
{
'email':inputEmail,
'password':inputPassword
},
cartItemServiceV2.ts
import axios, { AxiosResponse } from 'axios';
import { response } from 'msw';
import { CartItemResponse } from '../types/cartItem/cartItemResponse';
/**
*
* @remarks SpringBootで作ったAPIを呼ぶ関数
*
*
* @param jwtAccessKey
* @returns
*/
export const getCartItems2 = async (jwtAccessKey : string) => {
return await axios.get(`http://127.0.0.1:5000/api/cart/all`,
{
headers: {
'Authorization': `Bearer ${jwtAccessKey}`
}
}
)
.then((response) => {
{
return response.data as AxiosResponse<CartItemResponse>;
}
})
.catch((error) => {
console.log("cartItems are not set");
return error;
});
}
orderService.ts
import axios, { AxiosResponse } from 'axios';
export const createPaymentIntent = async (amount:number,currency:string,receiptEmail:string) =>{
return await axios.post(`http://127.0.0.1:5000/api/pay/payment-intent`,
{amount:amount,currency:currency,receiptEmail:receiptEmail},
{
headers: {
'Request-Method': 'POST',
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'http://127.0.0.1:5000/*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Credentials': 'true'
}
})
.then((response)=>{
return response.data;
})
.catch((error)=>{
return error;
});
}
export const completePayment = async (jwtAccessKey : string )=>{
return await axios.put(`http://127.0.0.1:5000/api/pay/finish`,{},
{ headers : {
'Authorization': `Bearer ${jwtAccessKey}`,
'Request-Method' : 'PUT',
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'http://127.0.0.1:5000/*',
'Access-Control-Allow-Headers': 'accept, accept-language, content-language, content-type',
'Access-Control-Allow-Credentials': 'true'
}}
).then((response)=>{
})
.catch((error) => {
});
}
CartitemResponse.d.ts
// CartItemを取得した時のレスポンスの型定義
export type CartItemResponse = {
// 各々商品の情報
cartItemDtos : CartItemDto[]
// 商品の合計額(税抜き)
productCost:number
// 配送料(4000円だと無料)
shippingCost:number
// ???
subTotal:number
// 消費税の合計額
tax:number
// お客様が支払う金額
total:number
}
CartItemDto.d.ts
export type CartItemDto={
id:number
customerId:number
productId:number
quantity:number
productName:string
priceWithoutTax:number
priceWithTax:number
}
動かして確認
以下gifアニメはクレカ決済を行っているときの様子です。
左側の画面:Reactで作った決済画面(http://127.0.0.1:3000/develop/checkOutForm)
右側の画面:Stripeの管理画面(https://dashboard.stripe.com/test/****)
未実装の箇所
支払いを行わない場合 未支払いの情報がたまる対処をしていない
クレカの入力画面を作成する時点で、データを作っているので支払わない場合データがたまります。
一定時間経過で未支払いの情報を削除する必要があります。(バッチファイルか?)