Source: line-pay.js

"use strict";

require("dotenv").config();

const router = require("express").Router();
const session = require("express-session");
const debug = require("debug")("line-pay:module");
const request = require("request");
const lossless_json = require("lossless-json");
const api_version = "v2";

let Error = require("./line-pay-error.js");
Promise = require("bluebird");
Promise.promisifyAll(request);

/**
@class
*/
class LinePay {
    /**
    @constructor
    @param {Object} options
    @param {String} options.channelId - LINE Channel Id
    @param {String} options.channelSecret - LINE Channel secret
    @param {String} [options.hostname] - Hostname of LINE Pay API. Nomarlly, it is automatically set depeding on isSandbox parameter.
    @param {Boolean} [options.isSandbox=false] - If the environemt is sandbox, set true
    @param {Object} [options.sessionOptions] - Option object for express-session. Refer to https://github.com/expressjs/session for detail.
    */
    constructor(options){
        const required_params = ["channelId", "channelSecret"];
        const optional_params = ["hostname", "isSandbox", "sessionOptions"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        this.channelId = options.channelId;
        this.channelSecret = options.channelSecret;
        this.isSandbox = options.isSandbox || false;
        if (this.isSandbox){
            this.apiHostname = "sandbox-api-pay.line.me";
        } else {
            this.apiHostname = "api-pay.line.me";
        }
        this.apiHostname = options.hostname || this.apiHostname;

        this.headers = {
            "X-LINE-ChannelId": this.channelId,
            "X-LINE-ChannelSecret": this.channelSecret,
            "Content-Type": "application/json"
        }

        this.sessionOptions = options.sessionOptions || {
            secret: options.channelSecret,
            resave: false,
            saveUninitialized: false
        }
        router.use(session(this.sessionOptions));
    }

    /**
    Middleware to start payment flow.
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.productName - Product name.
    @param {String} [options.productImageUrl] - URL of product image.
    @param {Number} options.amount - Payment amount.
    @param {String} options.currency - Currency following ISO4218.
    @param {String} [options.mid] - LINE member ID.
    @param {String} [options.oneTimeKey] - One time key.
    @param {String} [options.confirmUrl] - URL to transition after the payment approval. Default is CURRENT_PROTOCOL://CURRENT_HOSTNAME/MIDDLEWARE_MOUNT_POINT/confirm
    @param {String} [options.confirmUrlType="CLIENT"] - Confirm URL type. In this middleware, supported values are CLIENT only.
    @param {Boolean} [options.checkConfirmUrlBrowswer=false] - If check browser on transitioning to confirm URL.
    @param {String} [options.cancelUrl] - URL to transition after cancellation of payment.
    @param {String} [options.packageName] - String to prevent phising in Android.
    @param {String} options.orderId - Unique id of the order transaction.
    @param {String} [options.deliveryPlacePhone] - Contact of payment receiver.
    @param {String} [options.payType="NORMAL"] - Payment type. Supported values are NORMAL and PREAPPROVED.
    @param {String} [options.langCd] - Language to display payment pending screen.
    @param {Boolean} [options.capture=true] - Set true if like to complete payment right after successful of confirm API call.
    */
    middleware(options){
        router.get("/", (req, res, next) => {
            options.confirmUrl = options.confirmUrl || `https://${req.hostname}${req.baseUrl}/confirm`;

            req.session.productName = options.productName;
            req.session.orderId = options.orderId;
            req.session.amount = options.amount;
            req.session.currency = options.currency;
            req.session.confirmUrl = options.confirmUrl;

            this.reserve(options).then((response) => {
                req.session.transactionId = response.info.transactionId;

                if (true){ // TBD
                    //debug(`Redirecting to payment URL: ${response.info.paymentUrl.web}...`);
                    return res.redirect(response.info.paymentUrl.web);
                } else {
                    //debug(`Redirecting to payment URL: ${response.info.paymentUrl.app}...`);
                    return res.redirect(response.info.paymentUrl.app);
                }
            }).catch((exception) => {
                return res.status(400).json(exception);
            })
        });

        router.get("/confirm", (req, res, next) => {
            if (!req.query || !req.query.transactionId){
                return res.status(400).send("Transaction id not found.");
            }

            let transactionId = req.query.transactionId;
            this.confirm({
                transactionId: transactionId,
                amount: req.session.amount,
                currency: req.session.currency
            }).then((response) => {
                next();
            }).catch((exception) => {
                return res.status(500).json(exception);
            });
        });

        return router;
    }

    /**
    Method to reserve payment
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.productName - Product name.
    @param {String} [options.productImageUrl] - URL of product image.
    @param {Number} options.amount - Payment amount.
    @param {String} options.currency - Currency following ISO4218.
    @param {String} [options.mid] - LINE member ID.
    @param {String} [options.oneTimeKey] - One time key.
    @param {String} options.confirmUrl - URL to transition after the payment approval.
    @param {String} [options.confirmUrlType="CLIENT"] - Confirm URL type. Supported values are CLIENT and SERVER.
    @param {Boolean} [options.checkConfirmUrlBrowswer=false] - If check browser on transitioning to confirm URL.
    @param {String} [options.cancelUrl] - URL to transition after cancellation of payment.
    @param {String} [options.packageName] - String to prevent phising in Android.
    @param {String} options.orderId - Unique id of the order transaction.
    @param {String} [options.deliveryPlacePhone] - Contact of payment receiver.
    @param {String} [options.payType="NORMAL"] - Payment type. Supported values are NORMAL and PREAPPROVED.
    @param {String} [options.langCd] - Language to display payment pending screen.
    @param {Boolean} [options.capture=true] - Set true if like to complete payment right after successful of confirm API call.
    */
    reserve(options){
        const required_params = ["productName", "amount", "currency", "confirmUrl", "orderId"];
        const optional_params = ["productImageUrl", "mid", "oneTimeKey", "confirmUrlType", "checkConfirmUrlBrowser", "cancelUrl", "packageName", "deliveryPlacePhone", "payType", "langCd", "capture"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/request`;
        let body = JSON.stringify(options);
        debug(`Going to reserve payment...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            body: body
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed reserving payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to reserve payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Method to confirm payment
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.transactionId - Transaction id returned from reserve API.
    @param {Number} options.amount - Payment amount.
    @param {String} options.currency - Currency following ISO2117. Supported values are USD, JPY, TWD and THB.
    */
    confirm(options){
        const required_params = ["transactionId", "amount", "currency"];
        const optional_params = [];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/${options.transactionId}/confirm`;
        let body = JSON.stringify({
            amount: options.amount,
            currency: options.currency
        })
        debug(`Going to confirm payment of transaction: ${options.transactionId}...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            body: body
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed confirming payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to confirm payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }


    /**
    Method to confirm preapproved payment
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.regKey - Key which is returned from reserve API.
    @param {String} options.productName - Product name.
    @param {Number} options.amount - Payment amount.
    @param {String} options.currency - Payment currency.
    @param {String} options.orderId - Order id which specified in reserve API.
    @param {Boolean} [options.capture=true] - Set true to capture payment simultaneously.
    */
    confirmPreapprovedPay(options){
        const required_params = ["regKey", "productName", "amount", "currency", "orderId"];
        const optional_params = ["capture"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/preapprovedPay/${options.regKey}/payment`;
        delete options.regKey;
        let body = JSON.stringify(options);
        debug(`Going to execute preapproved payment of orderId: ${options.orderId}...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            body: body
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed executing preapproved payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to execute preapproved payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Method to check the availability of preapproved payment.
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.regKey - Key which is returned in reserve API.
    @param {Boolean} [options.creditCardAuth=false] - Set true to execute authorization payment in minimum amount by registered credit card.
    */
    checkPreapprovedPay(options){
        const required_params = ["regKey"];
        const optional_params = ["creditCardAuth"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/preapprovedPay/${options.regKey}/check`;
        if (options.creditCardAuth === true){
            url += "?creditCardAuth=true";
        }
        debug(`Going to check availability of preapproved payment for regKey: ${options.regKey}...`);
        return request.getAsync({
            url: url,
            headers: this.headers,
            json: true
        }).then((response) => {
            if (response.body.returnCode && response.body.returnCode == "0000"){
                debug(`Completed checking availability.`);
                debug(response.body);
                return response.body;
            } else {
                debug(`Failed to check availability.`);
                debug(response.body);
                return Promise.reject(new Error(response.body));
            }
        })
    }

    /**
    Method to expire preapproved payment.
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.regKey - Key which is returned by reserve API.
    */
    expirePreapprovedPay(options){
        const required_params = ["regKey"];
        const optional_params = [""];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/preapprovedPay/${options.regKey}/expire`;
        debug(`Going to expire of preapproved payment for regKey: ${options.regKey}...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            json: true
        }).then((response) => {
            if (response.body.returnCode && response.body.returnCode == "0000"){
                debug(`Completed expiring preapproved payment.`);
                debug(response.body);
                return response.body;
            } else {
                debug(`Failed to expire preapprove payment.`);
                debug(response.body);
                return Promise.reject(new Error(response.body));
            }
        })
    }

    /**
    Method to void authorized payment.
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.transactionId - Transaction id to void.
    */
    voidAuthorization(options){
        const required_params = ["transactionId"];
        const optional_params = [""];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/authorizations/${options.transactionId}/void`;
        debug(`Going to void of authorized payment for transaction id: ${options.transactionId}...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            json: true
        }).then((response) => {
            if (response.body.returnCode && response.body.returnCode == "0000"){
                debug(`Completed void payment.`);
                debug(response.body);
                return response.body;
            } else {
                debug(`Failed to void payment.`);
                debug(response.body);
                return Promise.reject(new Error(response.body));
            }
        })
    }

    /**
    Method to inquire authorization.
    @param {Object} options - Object which contains parameters.
    @param {String} [options.transactionId] - Transaction id to inquire. *While it is described that data type should be number, javascript cannot handle the scale of transactionId so please set string for this parameter.
    @param {String} [options.orderId] - Order id to inquire.
    */
    inquireAuthorization(options){
        const required_params = [];
        const optional_params = ["transactionId", "orderId"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/authorizations?`;
        if (options.transactionId) url += `transactionId=${options.transactionId}`;
        if (options.orderId) url += `orderId=${options.orderId}`;
        debug(`Going to inquire authorization...`);
        return request.getAsync({
            url: url,
            headers: this.headers
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed inquiring authorization.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to inquiring authorization.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Method to capture payment
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.transactionId - Transaction id returned from reserve API. *While it is described that data type should be number, javascript cannot handle the scale of transactionId so please set string for this parameter.
    @param {Number} options.amount - Payment amount.
    @param {String} options.currency - Currency following ISO2117. Supported values are USD, JPY, TWD and THB.
    */
    capture(options){
        const required_params = ["transactionId", "amount", "currency"];
        const optional_params = [];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/authorizations/${options.transactionId}/capture`;
        let body = JSON.stringify({
            amount: options.amount,
            currency: options.currency
        });
        debug(`Going to capture payment...`);
        delete body.transactionId;
        return request.postAsync({
            url: url,
            headers: this.headers,
            body: body
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed capturing payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to capture payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Method to inquire payment.
    @param {Object} options - Object which contains parameters.
    @param {String} [options.transactionId] - Transaction id to inquire.
    @param {String} [options.orderId] - Order id to inquire.
    */
    inquirePayment(options){
        const required_params = [];
        const optional_params = ["transactionId", "orderId"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments?`;
        if (options.transactionId) url += `transactionId=${options.transactionId}`;
        if (options.orderId) url += `orderId=${options.orderId}`;
        debug(`Going to inquire payment...`);
        return request.getAsync({
            url: url,
            headers: this.headers
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed inquiring payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to inquiring payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Method to refund payment
    @method
    @param {Object} options - Object which contains parameters.
    @param {String} options.transactionId - Transaction id to refund.
    @param {Number} [options.refundAmount] - Amount to refund.
    */
    refund(options){
        const required_params = ["transactionId"];
        const optional_params = ["refundAmount"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        let url = `https://${this.apiHostname}/${api_version}/payments/${options.transactionId}/refund`;
        let body = JSON.stringify({
            refundAmount: options.refundAmount
        });
        debug(`Going to refund payment...`);
        return request.postAsync({
            url: url,
            headers: this.headers,
            body: body
        }).then((response) => {
            let body = lossless_json.parse(response.body, this._lossless_converter);

            if (body.returnCode && body.returnCode == "0000"){
                debug(`Completed capturing payment.`);
                debug(body);
                return body;
            } else {
                debug(`Failed to refund payment.`);
                debug(body);
                return Promise.reject(new Error(body));
            }
        })
    }

    /**
    Retriever for lossless_json.parse() to convert overflowed number as string.
    */
    _lossless_converter(key, value){
        if (value && value.isLosslessNumber) {
            try {
                return value.valueOf();
            } catch (e) {
                return value.value;
            }
        } else {
            return value;
        }
    }

}

module.exports = LinePay;