UNPKG

@rytass/invoice-adapter-ecpay

Version:

Rytass Invoice Gateway - ECPay

624 lines (615 loc) 26.8 kB
'use strict'; var invoice = require('@rytass/invoice'); var axios = require('axios'); var isEmail = require('validator/lib/isEmail'); var luxon = require('luxon'); var crypto = require('crypto'); class ECPayInvoice { invoiceNumber; randomCode; issuedOn; issuedAmount; orderId; taxType; items; allowances = []; state = invoice.InvoiceState.ISSUED; voidOn = null; nowAmount; awardType; constructor(options){ this.issuedOn = options.issuedOn; this.items = options.items; this.nowAmount = options.items.reduce((sum, item)=>sum + item.quantity * item.unitPrice, 0); this.issuedAmount = this.nowAmount; this.randomCode = options.randomCode; this.invoiceNumber = options.invoiceNumber; this.orderId = options.orderId; this.taxType = options.taxType; this.awardType = options.awardType; if (options.isVoid) { this.setVoid(); } } setVoid() { this.state = invoice.InvoiceState.VOID; this.voidOn = new Date(); } async addAllowance(allowance) { this.allowances.push(allowance); this.nowAmount = allowance.remainingAmount; } } class ECPayInvoiceAllowance { allowanceNumber; allowancePrice; allowancedOn; remainingAmount; items; parentInvoice; status = invoice.InvoiceAllowanceState.INITED; invalidOn = null; constructor(options){ this.allowanceNumber = options.allowanceNumber; this.allowancePrice = options.allowancePrice; this.allowancedOn = options.allowancedOn; this.remainingAmount = options.remainingAmount; this.items = options.items; this.parentInvoice = options.parentInvoice; this.status = options.status; } invalid(invalidOn = new Date()) { this.invalidOn = invalidOn; this.status = invoice.InvoiceAllowanceState.INVALID; this.parentInvoice.nowAmount += this.allowancePrice; } } const ECPayCustomsMark = { [invoice.CustomsMark.NO]: '1', [invoice.CustomsMark.YES]: '2' }; const ECPayCarrierTypeCode = { [invoice.InvoiceCarrierType.PRINT]: '', [invoice.InvoiceCarrierType.LOVE_CODE]: '', [invoice.InvoiceCarrierType.MEMBER]: '1', [invoice.InvoiceCarrierType.PLATFORM]: '1', [invoice.InvoiceCarrierType.MOICA]: '2', [invoice.InvoiceCarrierType.MOBILE]: '3' }; const ECPayTaxTypeCode = { [invoice.TaxType.TAXED]: '1', [invoice.TaxType.TAX_FREE]: '2', [invoice.TaxType.ZERO_TAX]: '3', [invoice.TaxType.SPECIAL]: '4', [invoice.TaxType.MIXED]: '9' }; const ECPAY_INVOICE_SUCCESS_CODE = 1; const ECPAY_INVOICE_NOT_FOUND = 1600003; const ECPAY_COMPRESSED_ITEM_NAME = 'ECPAY/COMPRESS_ITEM'; const ECPAY_RANDOM_CODE = 'XXXX'; var ECPayBaseUrls = /*#__PURE__*/ function(ECPayBaseUrls) { ECPayBaseUrls["DEVELOPMENT"] = "https://einvoice-stage.ecpay.com.tw"; ECPayBaseUrls["PRODUCTION"] = "https://einvoice.ecpay.com.tw"; return ECPayBaseUrls; }({}); class ECPayInvoiceGateway { revision = '3.0.0'; aesIv = 'q9jcZX8Ib9LM8wYk'; aesKey = 'ejCk326UnaZWKisg'; merchantId = '2000132'; baseUrl = ECPayBaseUrls.DEVELOPMENT; skipMobileBarcodeValidation = false; skipLoveCodeValidation = false; encrypt(data) { const encodedData = encodeURIComponent(JSON.stringify(data)); const cipher = crypto.createCipheriv('aes-128-cbc', this.aesKey, this.aesIv); cipher.setAutoPadding(true); return [ cipher.update(encodedData, 'utf8', 'base64'), cipher.final('base64') ].join(''); } decrypt(encryptedData) { const decipher = crypto.createDecipheriv('aes-128-cbc', this.aesKey, this.aesIv); return JSON.parse(decodeURIComponent([ decipher.update(encryptedData, 'base64', 'utf8'), decipher.final('utf8') ].join(''))); } async isValidGUI(gui) { if (!/^\d{8}$/.test(gui)) { return [ false ]; } console.warn('GUI validation is not fully covered all companies or organization, this is a supporting feature to help you validate GUI format.'); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/GetCompanyNameByTaxID`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: Math.round(Date.now() / 1000) }, Data: this.encrypt({ MerchantID: this.merchantId, UnifiedBusinessNo: gui }) })); if (data.TransCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('Invalid Response on GUI Validator'); } const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { return [ false ]; } return [ true, payload.CompanyName ]; } async isLoveCodeValid(loveCode) { const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/CheckLoveCode`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: Math.round(Date.now() / 1000), Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, LoveCode: loveCode }) })); if (data.TransCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('Invalid Response on Love Code Validator'); } const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('Invalid Response on Love Code Validator'); } return payload.IsExist === 'Y'; } async isMobileBarcodeValid(barcode) { if (!/^\/[0-9A-Z+\-.]{7}$/.test(barcode)) { return false; } const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/CheckBarcode`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: Math.round(Date.now() / 1000), Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, BarCode: barcode }) })); if (data.TransCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('Invalid Response on Mobile Barcode Validator'); } const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('Invalid Response on Mobile Barcode Validator'); } return payload.IsExist === 'Y'; } constructor(options){ this.aesIv = options?.aesIv || this.aesIv; this.aesKey = options?.aesKey || this.aesKey; this.merchantId = options?.merchantId || this.merchantId; this.baseUrl = options?.baseUrl || this.baseUrl; this.skipMobileBarcodeValidation = options?.skipMobileBarcodeValidation ?? false; this.skipLoveCodeValidation = options?.skipLoveCodeValidation ?? false; } async issue(options) { if (/[^0-9a-z]/gi.test(options.orderId)) { throw new Error('`orderId` only allowed number and alphabet'); } if (!options.orderId || options.orderId.length > 30) { throw new Error('`orderId` is required and length less than 30'); } if (options.vatNumber && !/^\d{8}$/.test(options.vatNumber)) { throw new Error('Invalid VAT number format'); } if (options.customer.id) { if (options.customer.id.length > 20) throw new Error('`customer.id` max length is 20'); if (/[0-9a-z_]/gi.test(options.customer.id)) throw new Error('`customer.id` only allowed number, alphabets and underline'); } if (!options.customer.mobile && !options.customer.email) throw new Error('`customer.mobile` and `customers.email` should provide one'); if (options.vatNumber && !options.customer.name) { throw new Error('`customer.name` require the company name if `vatNumber` provided'); } if (options.carrier?.type === invoice.InvoiceCarrierType.PRINT) { if (!options.customer.name) throw new Error('`customer.name` is required if invoice printed'); if (!options.customer.address) throw new Error('`customer.address` is required if invoice printed'); } if (options.customer.email && !isEmail(options.customer.email)) { throw new Error('`customer.email` is invalid format'); } if (options.customer.mobile && !/^\d+$/.test(options.customer.mobile)) { throw new Error('`customer.mobile` only allowed number'); } if (options.vatNumber && options.carrier?.type !== invoice.InvoiceCarrierType.PRINT) { throw new Error('when `vatNumber` provided, carrier should be PRINT'); } if (options.carrier?.type === invoice.InvoiceCarrierType.LOVE_CODE) { // validate love code if (!this.skipLoveCodeValidation && !await this.isLoveCodeValid(options.carrier.code)) { throw new Error('Love code is invalid'); } } if (options.carrier?.type === invoice.InvoiceCarrierType.MOBILE) { // validate mobile if (!this.skipMobileBarcodeValidation && !await this.isMobileBarcodeValid(options.carrier.code)) { throw new Error('Mobile barcode is invalid'); } } if (options.carrier?.type === invoice.InvoiceCarrierType.MOICA && !/^[A-Z]{2}[0-9]{14}$/.test(options.carrier.code)) { throw new Error('invalid MOICA code'); } const taxType = invoice.getTaxTypeFromItems(options.items); if (taxType === invoice.TaxType.SPECIAL && !options.specialTaxCode) { throw new Error('`specialTaxCode` is required if special tax item provided'); } const now = Math.round(Date.now() / 1000); const amount = options.items.reduce((sum, item)=>sum + item.quantity * item.unitPrice, 0); if (amount <= 0) { throw new Error('invoice amount should more than zero'); } const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/Issue`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now, Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, RelateNumber: options.orderId, CustomerID: options.customer.id ?? '', CustomerIdentifier: options.vatNumber ?? '', CustomerName: options.customer.name ?? '', CustomerAddr: options.customer.address ?? '', CustomerPhone: options.customer.mobile ?? '', CustomerEmail: options.customer.email ?? '', ClearanceMark: ECPayCustomsMark[options.customsMark ?? invoice.CustomsMark.NO], Print: options.carrier?.type === invoice.InvoiceCarrierType.PRINT ? '1' : '0', Donation: options.carrier?.type === invoice.InvoiceCarrierType.LOVE_CODE ? '1' : '0', LoveCode: options.carrier?.type === invoice.InvoiceCarrierType.LOVE_CODE ? options.carrier.code : '', CarrierType: ECPayCarrierTypeCode[options.carrier?.type ?? invoice.InvoiceCarrierType.PRINT], CarrierNum: ~[ '2', '3' ].indexOf(ECPayCarrierTypeCode[options.carrier?.type ?? invoice.InvoiceCarrierType.PRINT]) ? options.carrier.code : '', TaxType: ECPayTaxTypeCode[taxType], SpecialTaxType: ~[ invoice.TaxType.TAXED, invoice.TaxType.TAX_FREE, invoice.TaxType.MIXED ].indexOf(taxType) ? 0 : taxType === invoice.TaxType.ZERO_TAX ? 8 : options.specialTaxCode, SalesAmount: amount, InvoiceRemark: options.remark ?? '', Items: options.items.map((item, index)=>({ ItemSeq: index + 1, ItemName: item.name, ItemCount: item.quantity, ItemWord: item.unit ?? '個', ItemPrice: item.unitPrice, ItemTaxType: !item.taxType || item.taxType === invoice.TaxType.SPECIAL ? ECPayTaxTypeCode[invoice.TaxType.TAXED] : ECPayTaxTypeCode[item.taxType], ItemAmount: item.quantity * item.unitPrice, ItemRemark: item.remark ?? null })), InvType: taxType === invoice.TaxType.SPECIAL ? '08' : '07', vat: '1' }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('ECPay issue failed'); } return new ECPayInvoice({ items: options.items, issuedOn: luxon.DateTime.fromFormat(payload.InvoiceDate, 'yyyy-MM-dd+HH:mm:ss').toJSDate(), invoiceNumber: payload.InvoiceNo, randomCode: payload.RandomNumber, orderId: options.orderId, taxType }); } throw new Error('ECPay gateway error'); } async void(invoice, options) { const now = Math.round(Date.now() / 1000); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/Invalid`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now, Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, InvoiceNo: invoice.invoiceNumber, InvoiceDate: luxon.DateTime.fromJSDate(invoice.issuedOn).toFormat('yyyy-MM-dd'), Reason: options.reason }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error('ECPay issue failed'); } if (payload.InvoiceNo) { invoice.setVoid(); } return invoice; } throw new Error('ECPay gateway error'); } async allowance(invoice$1, allowanceItems, options) { if (invoice$1.taxType === invoice.TaxType.MIXED && !options?.taxType) { throw new Error('Mixed invoice allowance must specify a tax type'); } const now = Math.round(Date.now() / 1000); const totalAmount = allowanceItems.reduce((acc, item)=>acc + item.quantity * item.unitPrice, 0); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/Allowance`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now, Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, InvoiceNo: invoice$1.invoiceNumber, InvoiceDate: luxon.DateTime.fromJSDate(invoice$1.issuedOn).toFormat('yyyy-MM-dd'), AllowanceNotify: (()=>{ if (options?.notifyEmail) { if (options?.notifyPhone) { return 'A'; } return 'E'; } if (options?.notifyPhone) { return 'S'; } return 'N'; })(), CustomerName: options?.buyerName ?? '', NotifyMail: options?.notifyEmail, NotifyPhone: options?.notifyPhone, AllowanceAmount: totalAmount, Items: allowanceItems.map((item, index)=>({ ItemSeq: index + 1, ItemName: item.name, ItemCount: item.quantity, ItemWord: item.unit ?? '式', ItemPrice: item.unitPrice, ...invoice$1.taxType === invoice.TaxType.MIXED ? { ItemTaxType: (()=>{ switch(options?.taxType){ case invoice.TaxType.TAXED: return '1'; case invoice.TaxType.ZERO_TAX: return '2'; case invoice.TaxType.TAX_FREE: return '3'; } })() } : {}, ItemAmount: item.quantity * item.unitPrice })) }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error(`ECPay allowance failed: (${payload.RtnMsg})`); } const allowance = new ECPayInvoiceAllowance({ allowanceNumber: payload.IA_Allow_No, allowancePrice: totalAmount, allowancedOn: luxon.DateTime.fromFormat(payload.IA_Date, 'yyyy-MM-dd HH:mm:ss').toJSDate(), remainingAmount: payload.IA_Remain_Allowance_Amt, items: allowanceItems, parentInvoice: invoice$1, status: invoice.InvoiceAllowanceState.ISSUED }); invoice$1.addAllowance(allowance); return invoice$1; } throw new Error('ECPay gateway error'); } async invalidAllowance(allowance, reason) { const now = Math.round(Date.now() / 1000); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/AllowanceInvalid`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now, Revision: this.revision }, Data: this.encrypt({ MerchantID: this.merchantId, InvoiceNo: allowance.parentInvoice.invoiceNumber, AllowanceNo: allowance.allowanceNumber, Reason: reason ?? '作廢折讓' }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error(`ECPay allowance failed: (${payload.RtnMsg})`); } allowance.invalid(); return allowance.parentInvoice; } throw new Error('ECPay gateway error'); } async query(options) { const now = Math.round(Date.now() / 1000); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/GetIssue`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now }, Data: this.encrypt({ MerchantID: this.merchantId, ...'orderId' in options ? { RelateNumber: options.orderId } : { InvoiceNo: options.invoiceNumber, InvoiceDate: luxon.DateTime.fromJSDate(options.issuedOn).toFormat('yyyy-MM-dd') } }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { const payload = this.decrypt(data.Data); if (payload.RtnCode !== ECPAY_INVOICE_SUCCESS_CODE) { throw new Error(`ECPay query failed: (${payload.RtnMsg})`); } return new ECPayInvoice({ items: payload.Items.map((item)=>({ name: item.ItemName, unitPrice: item.ItemPrice, quantity: item.ItemAmount, unit: item.ItemWord, taxType: ((taxType)=>{ switch(taxType){ case '1': return invoice.TaxType.TAXED; case '2': return invoice.TaxType.ZERO_TAX; case '3': return invoice.TaxType.TAX_FREE; default: return undefined; } })(item.ItemTaxType), remark: item.ItemRemark })), issuedOn: luxon.DateTime.fromFormat(payload.IIS_Create_Date, 'yyyy-MM-dd+HH:mm:ss').toJSDate(), isVoid: payload.IIS_Invalid_Status === '1', invoiceNumber: payload.IIS_Number, randomCode: payload.IIS_Random_Number, orderId: payload.IIS_Relate_Number, awardType: Number(payload.IIS_Award_Type), taxType: ((taxType)=>{ switch(taxType){ case '1': return invoice.TaxType.TAXED; case '2': return invoice.TaxType.ZERO_TAX; case '3': return invoice.TaxType.TAX_FREE; case '4': return invoice.TaxType.SPECIAL; case '9': return invoice.TaxType.MIXED; } })(payload.IIS_Tax_Type) }); } throw new Error('ECPay gateway error'); } async getInvoiceListInPage(options, page) { const now = Math.round(Date.now() / 1000); const { data } = await axios.post(`${this.baseUrl}/B2CInvoice/GetIssueList`, JSON.stringify({ MerchantID: this.merchantId, RqHeader: { Timestamp: now }, Data: this.encrypt({ MerchantID: this.merchantId, BeginDate: options.startDate, EndDate: options.endDate, NumPerPage: 200, ShowingPage: page, DataType: 1, Query_Award: options.onlyAward ? 1 : 0, Query_Invalid: options.onlyInvalid ? 1 : 0 }) })); if (data.TransCode === ECPAY_INVOICE_SUCCESS_CODE) { return data.Data.InvoiceData.map((invoice$1)=>new ECPayInvoice({ items: [ { name: ECPAY_COMPRESSED_ITEM_NAME, unitPrice: invoice$1.IIS_Sales_Amount, quantity: 1, unit: '式', taxType: ((taxType)=>{ switch(taxType){ case '2': return invoice.TaxType.ZERO_TAX; case '3': return invoice.TaxType.TAX_FREE; case '4': return invoice.TaxType.SPECIAL; case '1': default: return invoice.TaxType.TAXED; } })(invoice$1.IIS_Tax_Type), remark: '' } ], issuedOn: luxon.DateTime.fromFormat(invoice$1.IIS_Create_Date, 'yyyy-MM-dd HH:mm:ss').toJSDate(), isVoid: invoice$1.IIS_Invalid_Status === '1', invoiceNumber: invoice$1.IIS_Number, randomCode: ECPAY_RANDOM_CODE, orderId: invoice$1.IIS_Relate_Number, awardType: Number(invoice$1.IIS_Award_Type), taxType: ((taxType)=>{ switch(taxType){ case '2': return invoice.TaxType.ZERO_TAX; case '3': return invoice.TaxType.TAX_FREE; case '4': return invoice.TaxType.SPECIAL; case '9': return invoice.TaxType.MIXED; case '1': default: return invoice.TaxType.TAXED; } })(invoice$1.IIS_Tax_Type) })); } throw new Error('ECPay gateway error'); } async list(options) { const getData = async (allInvoices = [], page = 1)=>{ const invoices = await this.getInvoiceListInPage(options, page); if (invoices.length === 0) return allInvoices; if (invoices.length < 200) { return [ ...allInvoices, ...invoices ]; } return getData([ ...allInvoices, ...invoices ], page + 1); }; return getData(); } } exports.ECPAY_COMPRESSED_ITEM_NAME = ECPAY_COMPRESSED_ITEM_NAME; exports.ECPAY_INVOICE_NOT_FOUND = ECPAY_INVOICE_NOT_FOUND; exports.ECPAY_INVOICE_SUCCESS_CODE = ECPAY_INVOICE_SUCCESS_CODE; exports.ECPAY_RANDOM_CODE = ECPAY_RANDOM_CODE; exports.ECPayBaseUrls = ECPayBaseUrls; exports.ECPayCarrierTypeCode = ECPayCarrierTypeCode; exports.ECPayCustomsMark = ECPayCustomsMark; exports.ECPayInvoice = ECPayInvoice; exports.ECPayInvoiceAllowance = ECPayInvoiceAllowance; exports.ECPayInvoiceGateway = ECPayInvoiceGateway; exports.ECPayTaxTypeCode = ECPayTaxTypeCode; Object.prototype.hasOwnProperty.call(invoice, '__proto__') && !Object.prototype.hasOwnProperty.call(exports, '__proto__') && Object.defineProperty(exports, '__proto__', { enumerable: true, value: invoice['__proto__'] }); Object.keys(invoice).forEach(function (k) { if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) exports[k] = invoice[k]; });