LINE Pay v3をLambdaで実行する
カフェのAPIはAPI GatewayとLambdaのサーバーレス構成で構築しています。
v2までは決済サーバーのIPをホワイトリストに登録する必要があったので、VPC Lambdaを使ってIPを固定する必要がありました。
しかしv3からはIPの登録が必要なくなったので、通常のLambdaに切り替えてコストを削減できました。
本記事ではLINE Pay v3をTypescriptで実装し、Lambdaで実行する際の注意点などを紹介します。
前提知識
LINE Pay API(v3)の決済フロー
- まずはLINE Pay側に決済情報を作成するために、Request APIを実行します。成功すれば
paymentUrl
が返却されます。 paymentUrl
でLINE Payの画面に遷移してユーザーが決済を承認します。(Sandboxの場合ここが省略される)- ユーザーが承認したらConfirm APIを実行します。この時オーソリを分離することができます。(Request APIで指定)
- (任意)オーソリを分離した場合はCapture APIで決済を確定します。
- オーソリ状態でのキャンセルはVoid APIを、確定後の返金はRefund APIを使いましょう。
実際の動き
ユーザー側の挙動はv2の時と変わりありません。
こちらはウォークスルーを体験した後に、LIFFからLINE Payでの決済を選択したフローとなります。
環境
v3のSDKはpython版しかリリースされていないようなので、直接APIを実行する実装にしています。
- Typescript: 3.9.0
- node-fetch: 2.6.0
- json-bigint: 0.3.0
APIリクエストのためにnode-fetch
、JSON内のBigIntの処理のためにjson-bigint
を使っています。
Lambdaから呼ぶためにLayerに追加するか、Lambdaのデプロイパッケージに含めてください。
実装
Request API
環境変数に以下を追加しておきます。
- LINE_PAY_END_POINT: https://api-pay.line.me
LINE PayのAPI実行に必要なキー情報はSecret Managerに保存します。
- Channel ID
- Channel SecretKey
実際のコードからは所々省略していますが、実装はこんな感じです。
import AWS from 'aws-sdk'; import { utils } from './utils'; import { LinePayError } from './errors'; const secretsManager = new AWS.SecretsManager(); const crypto = require('crypto'); const fetch = require('node-fetch'); const jsonBigInt = require('json-bigint')({ storeAsString: true }); interface Payment { payment_id: string; amount: number; items: Item[]; store_code: string; store_name: string; } interface Item { image_url: string; item_id: string; item_name: string; tax_included_price: number; raw_price: number; quantity: number; } interface RequestBody { amount: number; currency: 'JPY'; orderId: string; packages: Package[]; options?: { payment?: { capture?: boolean } }; redirectUrls: { confirmUrl: string; confirmUrlType?: 'CLIENT' | 'SERVER' | 'NONE'; cancelUrl: string; }; } interface Package { id: string; amount: number; name: string; products: Product[]; } interface Product { id?: string; name: string; imageUrl?: string; quantity: number; price: number; originalPrice?: number; } interface RequestResponse { returnCode: string; returnMessage: string; info: { transactionId: string; paymentAccessToken: string; paymentUrl: { app: string; web: string; }; }; } interface ConfirmBody { amount: number; currency: 'JPY'; } interface ConfirmResponse { returnCode: string; returnMessage: string; info?: { orderId: string; transactionId: number; authorizationExpireDate?: string; regKey?: string; payInfo?: PayInfo[]; packages?: [{ id: string; amount: number }]; }; } interface PayInfo { method: 'CREDIT_CARD' | 'BALANCE' | 'DISCOUNT'; amount: number; creditCardNickname?: string; } interface CaptureBody { amount: number; currency: 'JPY'; } interface CaptureResponse { returnCode: string; returnMessage: string; info?: { orderId: string; transactionId: string; payInfo?: PayInfo[]; }; } interface RefundBody { refundAmount?: number; } interface RefundResponse { returnCode: string; returnMessage: string; info?: { refundTransactionId: string; refundTransactionDate: string; }; } interface VoidResponse { returnCode: string; returnMessage: string; info?: { refundTransactionId: string; refundTransactionDate: string; }; } interface LinepayHeader { 'Content-Type': string; 'X-LINE-ChannelId': string; 'X-LINE-Authorization-Nonce': string; 'X-LINE-Authorization': string; } export class LinePay { private async _headers( path: string, body: string = '' ): Promise<LinepayHeader> { const secretValue = await secretsManager .getSecretValue({ SecretId: 'line-pay', }) .promise(); if (secretValue.SecretString === undefined) throw new Error('line-pay secret is not found.'); const secret = JSON.parse(secretValue.SecretString); const nonce = utils.generateId('', 22); const message = secret.line_pay_channel_secret + path + body + nonce; const encrypt = crypto .createHmac('sha256', secret.line_pay_channel_secret) .update(message); const signature = encrypt.digest('base64'); return { 'Content-Type': 'application/json', 'X-LINE-ChannelId': secret.line_pay_channel_id, 'X-LINE-Authorization-Nonce': nonce, 'X-LINE-Authorization': signature, }; } private async _post(path: string, payload?: object): Promise<any> { const body = JSON.stringify(payload); const headers = await this._headers(path, body); const url = process.env.LINE_PAY_END_POINT + path; const res = await fetch(url, { method: 'POST', body, headers }); const resText = await res.text(); const resJson = jsonBigInt.parse(resText); //標準のJSON.parseだとtransactionIdが正しく取れない return resJson; } async request(payment: Payment): Promise<RequestResponse> { /* itemsのサンプルデータ const items = [ { image_url: 'https://devio.jp/images/deviocafe_wt-akb/img_3h75d4olqaeyt5u.png', item_id: 'item_1ifetpyzm1', item_name: 'コロコロワッフル プレーン', tax_included_price: 330, raw_price: 300, priority: 2 }, { image_url: 'https://devio.jp/images/deviocafe_wt-akb/img_2romc38go53e6sb.png', item_id: 'item_kb34pai4fd', item_name: 'レモンミントハーブキャンディー', tax_included_price: 308, raw_price: 280, priority: 1 } ]; */ const products = payment.items.map((item) => { return { name: item.item_name, price: item.tax_included_price, quantity: item.quantity, originalPrice: item.raw_price, // option imageUrl: item.image_url, // option }; }); const packages = [ { id: payment.store_code, name: payment.store_name, amount: payment.amount, products, }, ]; const options: RequestBody = { amount: payment.amount, currency: 'JPY', orderId: payment.payment_id, packages, options: { payment: { capture: true } }, // confirm時にcaptureする redirectUrls: { confirmUrl: `https://example/confirm?payment_id=${payment.payment_id}`, confirmUrlType: 'CLIENT', cancelUrl: `https://example/cancel`, }, }; const result = await this._post('/v3/payments/request', options); if (!result.info || result.returnCode !== '0000') throw new LinePayError( `Line Pay request Failed: ${payment.payment_id}` ); return result; } async confirm( transactionId: string, amount: number ): Promise<ConfirmResponse> { const options = { amount: amount, currency: 'JPY', }; const result = await this._post( `/v3/payments/${transactionId}/confirm`, options ); return result; } async refund( transactionId: string, amount: number ): Promise<RefundResponse> { // captured only const options: RefundBody = { refundAmount: amount, }; const result = await this._post( `/v3/payments/${transactionId}/refund`, options ); return result; } async void(transactionId: string): Promise<VoidResponse> { // confirmed only const result = await this._post( `/v3/payments/authorizations/${transactionId}/void` ); return result; } }
注意⚠️: transactionIdがJSのMAX_SAFE_INTEGERに引っかかる
TypescriptというよりJavascriptの仕様になりますが、LINE Pay側で発行されるtransactionIdが19桁の整数で返ってくるため、標準のJSON.parse()を使ってしまうと丸め処理がされてしまいます。
詳しくは以下の記事を参照ください。
ここでは json-bigint
を使って文字列に変換することでデータを取得しています。
注意⚠️: DynamoDBに登録する場合
上記と同じtransactionIdの部分になりますが、文字列に変換せずBigIntのままDynamoに登録する場合、現在(2020/07/20)Lambdaに適用されているsdkのバージョンではDynamoがBigIntに対応していない為、以下のエラーが発生します。
TypeError: Do not know how to serialize a BigInt
これは aws-sdk: ^2.656.0
であればBigIntに対応しているので、最新を当てれば回避できます。
confirmUrlType
Request APIを実行する際に、以下を用意する必要があります。
- confirmUrl・・・ユーザーが決済を承認して決済を確定するときに遷移するURL
- cancelUrl・・・LINE payの画面でユーザーが決済を取り消した場合に遷移するURL
confirmUrl遷移後に加盟店側の決済処理を終わらせ、cancelUrl遷移後であれば加盟店側の決済取り消し処理をする必要があります。
confirmUrlTypeは以下の3パターンあります。
- 'CLIENT'
- 決済完了画面を表示する場合はこれ
- 'SERVER'
- 決済完了画面を表示せず、サーバー側で決済完了処理だけする場合はこれ
- 'NONE'
- NONEにした場合、Check Payment Status APIを使ってユーザー承認したのを確認し決済完了させる