LINE Pay v3をLambdaで実行する

LINE Pay×Lambda×Typescript
2020.07.20

カフェのAPIはAPI GatewayとLambdaのサーバーレス構成で構築しています。

v2までは決済サーバーのIPをホワイトリストに登録する必要があったので、VPC Lambdaを使ってIPを固定する必要がありました。
しかしv3からはIPの登録が必要なくなったので、通常のLambdaに切り替えてコストを削減できました。

本記事ではLINE Pay v3をTypescriptで実装し、Lambdaで実行する際の注意点などを紹介します。

前提知識

LINE Pay API(v3)の決済フロー

  1. まずはLINE Pay側に決済情報を作成するために、Request APIを実行します。成功すればpaymentUrlが返却されます。
  2. paymentUrlでLINE Payの画面に遷移してユーザーが決済を承認します。(Sandboxの場合ここが省略される)
  3. ユーザーが承認したらConfirm APIを実行します。この時オーソリを分離することができます。(Request APIで指定)
  4. (任意)オーソリを分離した場合はCapture APIで決済を確定します。
  5. オーソリ状態でのキャンセルは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を使ってユーザー承認したのを確認し決済完了させる