@rytass/invoice-adapter-ecpay
Version:
Rytass Invoice Gateway - ECPay
626 lines (617 loc) • 26.9 kB
JavaScript
'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 (!invoice.isValidVATNumber(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 && !invoice.isValidVATNumber(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';
default:
return '1';
}
})()
} : {},
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.ItemCount,
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];
});