「割り勘？支払いはStripeで！」 ~Stripe Connect&Next.jsを使って決済用URLを発行する~

# はじめに
案件でStripe Connectの決済用URLを発行することになったので記事を書いてみました。
Stripe Connectの概念がちょっと難しいな思ったので、割り勘した時にPayPayで送金する時のイメージでサンプルコードを作ってみました。
以下のStripe Connect&Next.jsを参考に作りました。


https://docs.stripe.com/connect/onboarding/quickstart?connect-onboarding-surface=hosted&connect-dashboard-type=full&connect-economic-model=revshare&connect-loss-liability-owner=stripe&connect-charge-type=direct

# イメージ図
![スクリーンショット 2024-05-05 11.36.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/5f0267b9-8d99-5ae6-3b6f-67ed536084f7.png)

# サンプルリポジトリ
https://github.com/mikaijun/stripe-onboarding

# 補足
- この記事はテスト環境前提です
- refresh_urlについては触れませんので[ドキュメント](https://docs.stripe.com/api/account_links/create)をご覧ください
- コードは全てクライアントコンポーネントで実装してます(わかりやすいように)
- UIやエラーハンドリング等は最小限の実装になってます


# 1.請求URL発行するまでの準備
## 親Stripeアカウント作成
請求する人(以降、子アカウント)が決済用URLを発行できるようにします。
以下のリンクからプラットフォーム側(以降、親アカウント)のStripeアカウント作成。
親アカウントで請求や入金はしないので案内通りに作成すればいいと思います

https://dashboard.stripe.com/register

## 親Stripeアカウント設定
### アカウント作成後テスト用に切り替えます
![【親】作成後テスト用に切り替える.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/df22ad39-10e1-325e-4f99-e7b05e964113.png)

### シークレットキーを確認
![【親】シークレットキー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/61c28f38-9153-e464-62a3-9d869ee8908c.png)

### シークレットキーを.envにペースト
```.env
NEXT_PUBLIC_BASE_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_51PCd...
```

### Connectを選択
Connectを作成する時に色々聞かれるので困ったら回答内容を参考にしてください
<details><summary>回答内容</summary>

![【親】ビジネス種類.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/8a2e702f-6c67-5504-6074-00e2fc08750a.png)
![【親】ホスティング.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/9eb99052-6958-56d3-b1b4-919cecc0e937.png)
![【親】ダッシュボード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/d664dcf7-37af-a7e3-7bf9-56450fd291a7.png)

</details>

![【親】Connect.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/985911f3-a765-aa74-1eaa-586484590274.png)

### Connect作成したら子アカウントの作成UIを設定

![【親】インターフェイス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/c9974f2e-669c-f506-4ef3-16c78e730e18.png)

### 作成するためにテスト環境を解除
![【親】テスト環境はずす.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/72f19955-2ba4-b876-c91f-01cbf72bd70b.png)

### アカウント作成UIを設定
![【親】UI設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/43d5a88e-3380-7a10-ac97-925d7ef27ad4.png)


# 2.請求URL発行手順
## 子Stripeアカウント作成画面
サンプルリポジトリのhttp://localhost:3000/ にアクセスすると以下のような画面が出るので連携ボタンをクリック
![【子】連携画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/47eacec8-2fde-2d53-b63d-efadaed243e9.png)
```src/app/page.tsx
"use client";

import React, { useCallback, useState } from "react";
import { CreateAccountResponse } from "./api/account/route";

export default function Home() {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isError, setIsError] = useState<boolean>(false);

  const handleConnect = useCallback(async () => {
    setIsLoading(true);
    setIsError(false);
    try {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/account`,
        { method: "POST" }
      );
      const json = (await res.json()) as CreateAccountResponse;
      const { url } = json;
      if (url) {
        window.location.href = url;
      } else {
        setIsError(true);
        setIsLoading(true);
      }
    } catch (error) {
      console.error(error);
      setIsError(true);
      setIsLoading(true);
    }
  }, []);
  return (
    <div>
      <h2>Stripe連携</h2>
      {/* 実際プロダクトで連携する時はDBにStripeのアカウント情報があるかどうかを確認し、既に連携済みの場合はボタンではなくアカウント情報を載せます */}
      {!isLoading && <button onClick={handleConnect}>連携する</button>}
      {isError && <p>エラーが発生しました</p>}
      {isLoading && <div>{isLoading && <p>Loading ...</p>}</div>}
    </div>
  );
}

```
```src/app/api/account/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

export type CreateAccountResponse = {
  url: string;
  message?: string;
};

export async function POST(): Promise<NextResponse<CreateAccountResponse>> {
  try {
    // 親(プラットフォーム管理者)のStripeのAPIキーを使ってStripeのクライアントを作成
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "");

    // 子(利用者)のStripeアカウントを作成。ユニークなID(string)を生成するのが目的
    // see: https://docs.stripe.com/api/accounts/create
    const account = await stripe.accounts.create({});
    const accountId = account.id;

    // Stripeのアカウントリンクを作成
    // ここからStripeのアカウントを連携させる(初めてStripeを利用する場合はアカウント作成も行う
    // see: https://docs.stripe.com/api/account_links/create
    const accountLink = await stripe.accountLinks.create({
      account: accountId,
      refresh_url: `${process.env.NEXT_PUBLIC_BASE_URL}/refresh/${accountId}`,
      return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/return/${accountId}`,
      type: "account_onboarding",
    });

    return NextResponse.json({ url: accountLink.url }, { status: 200 });
  } catch (e) {
    const error = e as Error;
    return NextResponse.json(
      { url: "", message: error.message || "Internal Server Error" },
      { status: 500 }
    );
  }
}
```

## Stripe作成画面にリダイレクト
サンプルリポジトリの一部を引用
```src/app/page.tsx
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/account`, { method: "POST" });
const json = (await res.json()) as CreateAccountResponse;
const { url } = json;
if (url) {
// ここでStripe画面にリダイレクト
window.location.href = url;
} else {
setIsError(true);
setIsLoading(true);
}
```

## 子Stripeアカウント作成

子Stripeアカウントを作成する時に色々聞かれるので困ったら回答内容を参考にしてください
<details><summary>回答内容</summary>

本人確認はSMSでいいかなと思います
![【子】電話番号.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/0c22a36c-13bb-a665-bc2f-ffbe9f34c297.png)

ウェブサイトのテストデータは[こちら](https://docs.stripe.com/connect/testing?locale=ja-JP)を引用
![【子】ウェブサイト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/13086c12-44f7-fffc-f471-4666d0470ee3.png)

質問に関しては全て「いいえ」でいいと思います
![【子】質問.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/43b72487-8e48-5711-00d0-13cd02554e9f.png)

「権限が必要です」が出ているので個人情報を編集
![【子】権限が必要です.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/e4ee4b55-c5d3-95c0-fe9a-d8979f7e40c0.png)

テストなので本人確認はテスト文章でOKかなと思います
![【子】テスト文章.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/a76bff23-6327-deac-cd29-2331ec7e50fa.png)



</details>

![【子】アカウント作成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/13401979-60ee-7e57-a907-85257e14d9e6.png)

## 金額入力画面
Stripe連携が完了すると`/return/${id}`にリダイレクトされます。
画面読み込み時に`${id}`のStripeアカウントが登録済みかどうか確認します

![スクリーンショット 2024-05-05 12.54.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/1b2809f1-74a9-d73f-b030-606663e9e83d.png)

```src/app/return/[id]/page.tsx
"use client";

import { CreateProductResponse } from "@/app/api/product/[id]/route";
import { RetrieveResponse } from "@/app/api/retrieve/[id]/route";
import Link from "next/link";
import React, { useCallback, useEffect, useState } from "react";

export default function Return({ params }: { params: { id: string } }) {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [accountId, setAccountId] = useState<string>("");
  const [price, setPrice] = useState<string>("");
  const [name, setName] = useState<string>("");
  const [url, setUrl] = useState<string>("");
  const [errorMessage, setErrorMessage] = useState<string>("");

  const handleChangePrice = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setPrice(e.target.value);
    },
    []
  );

  const handleChangeName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setName(e.target.value);
    },
    []
  );

  const createProduct = useCallback(async () => {
    try {
      setIsLoading(true);
      setErrorMessage("");
      const data = { price, name };
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/product/${params.id}`,
        { method: "POST", body: JSON.stringify(data) }
      );
      const json = (await res.json()) as CreateProductResponse;
      if (res.status === 200) {
        setUrl(json.url);
        setIsLoading(false);
      } else {
        setErrorMessage(json.message ?? "エラーが発生しました");
        setIsLoading(false);
      }
    } catch (error) {
      console.error(error);
      setIsLoading(false);
    }
  }, [params, price, name]);

  useEffect(() => {
    async function fetchData() {
      try {
        setIsLoading(true);
        const res = await fetch(
          `${process.env.NEXT_PUBLIC_BASE_URL}/api/retrieve/${params.id}`,
          {
            method: "GET",
          }
        );
        const json = (await res.json()) as RetrieveResponse;
        if (res.status === 200) {
          setAccountId(json.accountId);
        } else {
          setErrorMessage(json.message ?? "エラーが発生しました");
        }
      } catch (error) {
        setErrorMessage("エラーが発生しました");
      } finally {
        setIsLoading(false);
      }
    }
    fetchData();
  }, [params]);

  return (
    <div>
      {accountId && !isLoading && <p>StripeのID: {accountId}</p>}
      {accountId && !url && !isLoading && (
        <div>
          <div>金額</div>
          <input onChange={handleChangePrice} />
          <div style={{ marginTop: "16px" }}>件名</div>
          <input onChange={handleChangeName} />
          <div style={{ marginTop: "32px" }}>
            <button onClick={createProduct}>決済URL生成</button>
          </div>
        </div>
      )}
      {url && (
        <Link href={url} target="_blank">
          決済URLはこちらから
        </Link>
      )}
      {errorMessage && (
        <div>
          <h2>エラーが発生しました</h2>
          <p>{errorMessage}</p>
        </div>
      )}
      {isLoading && <div>{isLoading && <p>Loading ...</p>}</div>}
    </div>
  );
}

```

```src/app/api/retrieve/[id]/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

export type RetrieveResponse = {
  accountId: string;
  message?: string;
};

export async function GET(
  _: Request,
  { params }: { params: { id: string } }
): Promise<NextResponse<RetrieveResponse>> {
  try {
    // 親(プラットフォーム管理者)のStripeのAPIキーを使ってStripeのクライアントを作成
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "");

    // 子(利用者)のStripeアカウントを取得
    // see: https://docs.stripe.com/api/accounts/retrieve
    const account = await stripe.accounts.retrieve(params.id);

    // Stripeのアカウント情報が登録されているかどうかを確認。Stripe登録画面で「戻る」をクリックしたらdetails_submittedがfalseになるので、その場合はエラーを返す
    if (account.details_submitted) {
      return NextResponse.json({ accountId: account.id }, { status: 200 });
    } else {
      return NextResponse.json(
        { accountId: "", message: "情報入力が未完了です" },
        { status: 400 }
      );
    }
  } catch (e) {
    const error = e as Error;
    return NextResponse.json(
      { accountId: "", message: error.message || "Internal Server Error" },
      { status: 500 }
    );
  }
}
```


## 決済用URL生成
決済URLが生成されると以下のような画面になります。
URL生成処理に関しては下記コードになります
![【決済URL】.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/ac8a174e-dce8-1fca-348f-5b933fcc3a36.png)


```src/app/api/product/[id]/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";

export type CreateProductResponse = {
  url: string;
  message?: string;
};

export async function POST(
  request: Request,
  { params }: { params: { id: string } }
): Promise<NextResponse<CreateProductResponse>> {
  const json = await request.json();
  const unit_amount = Number(json.price);

  if (!Number.isInteger(unit_amount)) {
    return NextResponse.json(
      { url: "", message: "金額は整数で入力してください" },
      { status: 400 }
    );
  }

  if (0 >= unit_amount) {
    return NextResponse.json(
      { url: "", message: "金額は0より大きい金額で入力してください" },
      { status: 400 }
    );
  }

  try {
    // 親(プラットフォーム管理者)のStripeのAPIキーを使ってStripeのクライアントを作成。
    // Stripeの子(利用者)のIDを指定してStripeのクライアントを作成することで、そのアカウントに対しての操作が可能になる
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "", {
      stripeAccount: params.id,
    });

    // Stripeの商品を作成
    // see: https://stripe.com/docs/api/products/create
    const product = await stripe.products.create({
      name: json?.name || "請求の件",
    });

    // 商品の価格を設定
    // see: https://stripe.com/docs/api/prices/create
    const price = await stripe.prices.create({
      currency: "JPY",
      unit_amount,
      product: product.id,
    });

    // Stripeの支払いリンクを作成。これを子(利用者)から第三者(納付者)に送信することで、支払いを受け付けることができる
    // see: https://docs.stripe.com/connect/payment-links?locale=ja-JP
    const link = await stripe.paymentLinks.create(
      { line_items: [{ price: price.id, quantity: 1 }] },
      { stripeAccount: params.id }
    );

    return NextResponse.json({ url: link.url }, { status: 200 });
  } catch (e) {
    const error = e as Error;
    return NextResponse.json(
      { url: "", message: error.message || "Internal Server Error" },
      { status: 500 }
    );
  }
}
```

# 3.請求URLから支払う
上記2の「決済用URL生成」で生成したURLにアクセスすると以下のような画面が表示されるのでクレジットカード情報を入力。テストカード情報は[こちら](https://docs.stripe.com/testing?testing-method=card-numbers#visa)から引用

![【子】決済画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/81c21d8e-f210-e541-a558-73cdc9a025ee.png)

![【決済完了】.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/815ff2f1-991e-5f93-de6b-9e784261fe5c.png)


# 4.入金確認
**子アカウントでログイン**し、ホームにアクセスすると入金情報が確認できます。
親アカウントでログインしてホームを確認すると、親アカウントでは入金されていないことがわかります。
![【子】売上.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/840696/3587ab7e-24ec-8e06-c559-dd29feb6c65e.png)



# 終わりに
Stripe Connectに関して色々調べた所、支払う人がECサイト等のプラットフォームにログインして決済するという記事が多かったと思います。最初はStripeの概念に色々迷いましたが、ドキュメントのサンプルコードを色々試してみると結構早く理解できるかなと思います。この記事が参考になれば幸いです。
最後までお読みいただきありがとうございます！