SpringBoot(Kotlin) + PayPay for Developersで「ウェブペイメント」を実装

SpringBootを使って、PayPay for Developersで、「[ウェブペイメント](https://developer.paypay.ne.jp/products/docs/webpayment)」による支払いを実装してみました。
[ソースはこちら](https://github.com/145uoka/sample_paypay_web_payment)

**ウェブペイメント**については、公式によると、、
> あなたのサービスからPayPay Webページ、またはPayPayアプリに遷移して行う決済を提供します。
ウェブペイメント-即時＆出荷売上 は以下のような場合に採用することをお勧めしております
・モバイルアプリケーションから支払いが必要な場合
・ウェブサイトから支払いが必要な場合
・スマホ、ウェブブラウザ向けの両方をお持ちの場合、統一したインタフェースにより支払いを提供することが可能です。

だ、そうです。
つまりは、Web画面にPayPayの支払いサイト一旦遷移させて決済する仕組みのようです。

# 目次
1. [開発環境](#開発環境)
2. [事前にやること](#事前にやること)
2.1 [developerサイトでマーチャントアカウントを登録する](#developerサイトでマーチャントアカウントを登録する)
2.2 [APIキー等を取得](#apiキー等を取得)
3. [完成イメージ](#完成イメージ)
4. [実装してみる](#実装してみる)
4.1 [SDKの取り込み](#sdkの取り込み)
4.2 [APIキー類をproperty化](#apiキー類をproperty化)
4.3 [ApiClientをcomporent化](#apiclientをcomporent化)
4.4 [商品選択ページ](#商品選択ページ)
4.5 [購入ページ](#購入ページ)
4.6 [取引結果ページ](#取引結果ページ)


# 開発環境
| ツール | バージョン | 補足 |
|:-:|:-:|:-:|
| OS  |  macOS catalina |   |
| 言語 | Kotlin  |   |
| FW  | SpringBoot 2.6.4  |   |
| PayPaySDK  | paypayopa:1.0.6  | [MavenRepository](https://mvnrepository.com/artifact/jp.ne.paypay/paypayopa/1.0.6)  |



# 事前にやること。
## developerサイトでマーチャントアカウントを登録する
いわゆる店舗としてのアカウントを登録します。
[SignUp](https://developer.paypay.ne.jp/account/signup)より、アカウント登録を実施。
希望のメールアドレス、パスワードを入力すると、ワンタイムパスワードが送られてきます。
送られてきたワンタイムパスワードを入力すると、アカウント発行完了となります。
![スクリーンショット 2022-03-14 16.17.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/f9ac9e88-b219-1590-1e84-6432ae3bedde.png)


## APIキー等を取得
以下の項目を控えておく
* 加盟店ID
* APIキー
* シークレット
![スクリーンショット 2022-03-14 22.53.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/5a26f871-223c-5dcb-6cec-ccb955325a8c.png)

# 完成イメージ
以下のような画面遷移でPayPayの支払いを実装してみます。
![スクリーンショット 2022-03-15 23.52.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/c0a51bf2-a99a-8e3f-ab83-18ce3e7c1dd8.png)


# 実装してみる
developerの登録も完了したので、いざ実装に取り掛かる。
まずは、以下を参考にSpringBoot環境を用意。

[SpringBoot + kotlin + Thymeleafの構築](https://qiita.com/145uoka/items/e24c341e8a4d603b1122)

## SDKの取り込み
gradleの依存関係に以下を追加。
[paypayopa](https://mvnrepository.com/artifact/jp.ne.paypay/paypayopa/1.0.6)のみ追加するつもりだったが、SDK内で使用されているvalidationのバージョンの関係でコンパイルエラーに。。。
なので、validation類も追加。
``` gradle:build.gradle.kts
dependencies {
	〜割愛〜
    implementation("org.hibernate.validator:hibernate-validator-annotation-processor:7.0.4.Final")
    implementation("org.glassfish:jakarta.el:4.0.2")
    implementation("org.glassfish:javax.el:3.0.1-b11")
    implementation("jakarta.validation:jakarta.validation-api:3.0.0")
    implementation("jp.ne.paypay:paypayopa:1.0.6")
}
```

## APIキー類をproperty化
以下のpropertyについて、上記で取得した値に変更する
```yaml:application.yaml
paypay:
  production-mode: false # true:本番モード, false: テストモード
  api-key: [YOUR_API_KEY]                 # 発行されたAPIキー
  api-secret-key: [YOUR_SECRET_KEY]       # 発行されたシークレット
  assume-merchant: [YOUR_ASSUME_MERCHANT] # 発行された店舗ID
```

``` kt:PaypayApiProperties.kt
@ConstructorBinding
@ConfigurationProperties(prefix = "paypay")
data class PaypayApiProperties(
    val productionMode: Boolean,
    val apiKey: String,
    val apiSecretKey: String,
    val assumeMerchant: String
)
```

```kt:DemoApplication.kt
@SpringBootApplication
@ConfigurationPropertiesScan // 追加
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}
```

## ApiClientをcomporent化
```kt:PaypayApiClientConfiguration.kt
@Configuration
class PaypayApiClientConfiguration(
    private val paypayApiProperties: PaypayApiProperties
) {

    @Bean
    fun apiClient(): ApiClient {
        val apiClient = jp.ne.paypay.Configuration().defaultApiClient
        apiClient.setProductionMode(paypayApiProperties.productionMode)
        apiClient.setApiKey(paypayApiProperties.apiKey)
        apiClient.setApiSecretKey(paypayApiProperties.apiSecretKey)     
        apiClient.setAssumeMerchant(paypayApiProperties.assumeMerchant)
        return apiClient
    }
}
```

## 商品選択ページ
```kt:SampleController.kt
    @GetMapping("/")
    fun index(): ModelAndView {
        return ModelAndView("selectProduct")
    }
```
```html:selectProduct.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head>
    <title>商品</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>

<body>
<h1>商品</h1>
<table border="1">
    <thead>
    <tr>
        <th>商品コード</th>
        <th>商品名</th>
        <th>単価</th>
    </tr>
    </thead>
    <tbody>
    <tr>
        <th>XXX001</th>
        <th>バウムクーヘン</th>
        <th>500</th>
    </tr>
    </tbody>
</table>
<form method="post" th:action="@{/order}">
    <p>購入個数
    <select name="quantity">
        <option value="2">2</option>
    </select>個
    </p>
    <input type="submit" value="購入">
</form>
</body>
</html>
```

[http://localhost:8080](http://localhost:8080)にアクセスで、商品選択ページを表示。
![スクリーンショット 2022-03-15 23.15.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/435451d8-3cb6-c513-bede-7ee18992a5b9.png)


## 購入ページ
[PayPayAPIのCreate a Code](https://www.paypay.ne.jp/opa/doc/jp/v1.0/webcashier#operation/createQRCode)を実行して、支払い用のQRコードを発行させます。

```kt:SampleController.kt
@Controller
class SampleController(
    private val paypayApiAdaptor: PaypayApiAdaptor
) {
    @PostMapping("/order")
    fun order(
        @RequestParam("quantity") quantity: Int
    ): ModelAndView {
        val merchantPaymentId = Instant.now().toEpochMilli().toString()

        val orderItems = listOf(
            MerchantOrderItem()
                .name("バウムクーヘン")
                .category("ケーキ")
                .productId("XXX001")
                .quantity(quantity)
                .unitPrice(MoneyAmount().amount(500).currency(MoneyAmount.CurrencyEnum.JPY))
        )

        val response = paypayApiAdaptor.createQrCode(
            merchantPaymentId = merchantPaymentId,
            amount = orderItems.sumOf { it.unitPrice.amount * it.quantity },
            orderItems = orderItems,
            orderDescription = "注文説明",
            isAuthorization = null,
            redirectUrl = "http://localhost:8080/paymentDetails/%s".format(merchantPaymentId),
            redirectType = QRCode.RedirectTypeEnum.WEB_LINK,
            userAgent = null
        )
        println(response)

        return ModelAndView("redirect:" + response.data.url)
    }
}
```

```kt:PaypayApiAdaptor.kt
@Component
class PaypayApiAdaptor(
    private val apiClient: ApiClient
) {

    fun createQrCode(
        merchantPaymentId: String,
        amount: Int,
        orderItems: List<MerchantOrderItem>,
        orderDescription: String?,
        isAuthorization: Boolean?,
        redirectUrl: String?,
        redirectType: QRCode.RedirectTypeEnum?,
        userAgent: String?
    ): QRCodeDetails {
        val qrCode = QRCode()
        qrCode.amount = MoneyAmount().amount(amount).currency(MoneyAmount.CurrencyEnum.JPY)
        qrCode.merchantPaymentId = merchantPaymentId
        qrCode.codeType = "ORDER_QR"
        qrCode.orderItems = orderItems
        orderDescription?.let { qrCode.orderDescription = it }
        isAuthorization?.let { qrCode.isAuthorization(isAuthorization) }
        redirectUrl?.let { qrCode.redirectUrl = redirectUrl }
        redirectType?.let { qrCode.redirectType = redirectType }
        userAgent?.let { qrCode.userAgent = userAgent }

        return PaymentApi(apiClient).createQRCode(qrCode)
    }
}
```

`PaymentApi(apiClient).createQRCode(qrCode)`で、取引用のQRコードをPayPay側に作成します。
responseに含まれる、`data.url`へアクセスするとPayPayの支払い画面へ遷移するので、Controllerの返却を`data.url`にredirectさせます。

redirectした結果
![スクリーンショット 2022-03-15 23.24.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/1f15c112-479a-7b96-cd56-b7753825bbf0.png)

テストアカウントでログインして支払いしてみます。
テストアカウントはdeveloperサイトのダッシュボードの「**テストユーザ**」タブに記載されています。
![スクリーンショット 2022-03-15 23.41.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/e72870fd-f63a-5013-8209-276779372775.png)

ログインして支払い
![スクリーンショット 2022-03-15 23.25.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/8c6079ae-7aa5-0767-7482-effd3fa041b1.png)

支払い完了
![スクリーンショット 2022-03-15 23.26.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/4d9695e2-10de-999b-51f0-3b1941c457a5.png)

数秒後に、[PayPayAPIのCreate a Code](https://www.paypay.ne.jp/opa/doc/jp/v1.0/webcashier#operation/createQRCode)でリクエストした`redirectUrl`にリダイレクトします。
今回は後述の[取引結果ページ](#取引結果ページ)へ遷移させます。


## 取引結果ページ
取引結果では、[PayPayAPIのGet payment details](https://www.paypay.ne.jp/opa/doc/jp/v1.0/webcashier#operation/getPaymentDetails)を利用して、取引状態を取得します。

```kt: SampleController.kt
@Controller
class SampleController(
    private val paypayApiAdaptor: PaypayApiAdaptor
) {
    @GetMapping("/paymentDetails/{merchantPaymentId}")
    fun getPaymentDetails(
        @PathVariable("merchantPaymentId") merchantPaymentId: String
    ): ModelAndView {
        val response = paypayApiAdaptor.getPaymentDetails(merchantPaymentId = merchantPaymentId)
        print(response)
        return ModelAndView("paymentDetails").addObject("result", response.data)
    }
}
```
```kt:PaypayApiAdaptor.kt
@Component
class PaypayApiAdaptor(
    private val apiClient: ApiClient
) {
    fun getPaymentDetails(
        merchantPaymentId: String
    ): PaymentDetails {
        return PaymentApi(apiClient).getCodesPaymentDetails(merchantPaymentId)
    }
}
```

```html:paymentDetails.html
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

<head>
    <title>取引結果</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>

<body>
<h1>取引結果</h1>
<table border="1">
    <thead>
    <tr>
        <th>決済取引ID</th>
        <th>status</th>
        <th>トランザクションID</th>
        <th>売上</th>
        <th>通貨</th>
    </tr>
    </thead>
    <tbody>
    <tr>
        <td th:text="${result.paymentId}"></td>
        <td th:text="${result.status}"></td>
        <td th:text="${result.merchantPaymentId}"></td>
        <td th:text="${result.amount.amount}"></td>
        <td th:text="${result.amount.currency}"></td>
    </tr>
    </tbody>
</table>
</body>
</html>
```

実際の画面。
![スクリーンショット 2022-03-15 23.34.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2585352/e8b3a594-a7ec-2634-441a-6eaff3ae645b.png)
statusが「**COMPLETE**」であれば、「**取引完了**」です。

今回実装した[内容](https://github.com/145uoka/sample_paypay_web_payment)です。