@rytass/invoice-adapter-ecpay
Version:
Rytass Invoice Gateway - ECPay
516 lines (513 loc) • 23.2 kB
JavaScript
import { isValidVATNumber, InvoiceCarrierType, getTaxTypeFromItems, TaxType, CustomsMark, InvoiceAllowanceState } from '@rytass/invoice';
import axios from 'axios';
import isEmail from 'validator/lib/isEmail';
import { DateTime } from 'luxon';
import { createCipheriv, createDecipheriv } from 'crypto';
import { ECPAY_INVOICE_SUCCESS_CODE, ECPayTaxTypeCode, ECPayCarrierTypeCode, ECPayCustomsMark, ECPAY_RANDOM_CODE, ECPAY_COMPRESSED_ITEM_NAME } from './constants.js';
import { ECPayBaseUrls } from './typings.js';
import { ECPayInvoice } from './ecpay-invoice.js';
import { ECPayInvoiceAllowance } from './ecpay-allowance.js';
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 = 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 = 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 (!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 && !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 === 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 !== InvoiceCarrierType.PRINT) {
throw new Error('when `vatNumber` provided, carrier should be PRINT');
}
if (options.carrier?.type === 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 === InvoiceCarrierType.MOBILE) {
// validate mobile
if (!this.skipMobileBarcodeValidation && !await this.isMobileBarcodeValid(options.carrier.code)) {
throw new Error('Mobile barcode is invalid');
}
}
if (options.carrier?.type === InvoiceCarrierType.MOICA && !/^[A-Z]{2}[0-9]{14}$/.test(options.carrier.code)) {
throw new Error('invalid MOICA code');
}
const taxType = getTaxTypeFromItems(options.items);
if (taxType === 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 ?? CustomsMark.NO],
Print: options.carrier?.type === InvoiceCarrierType.PRINT ? '1' : '0',
Donation: options.carrier?.type === InvoiceCarrierType.LOVE_CODE ? '1' : '0',
LoveCode: options.carrier?.type === InvoiceCarrierType.LOVE_CODE ? options.carrier.code : '',
CarrierType: ECPayCarrierTypeCode[options.carrier?.type ?? InvoiceCarrierType.PRINT],
CarrierNum: ~[
'2',
'3'
].indexOf(ECPayCarrierTypeCode[options.carrier?.type ?? InvoiceCarrierType.PRINT]) ? options.carrier.code : '',
TaxType: ECPayTaxTypeCode[taxType],
SpecialTaxType: ~[
TaxType.TAXED,
TaxType.TAX_FREE,
TaxType.MIXED
].indexOf(taxType) ? 0 : taxType === 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 === TaxType.SPECIAL ? ECPayTaxTypeCode[TaxType.TAXED] : ECPayTaxTypeCode[item.taxType],
ItemAmount: item.quantity * item.unitPrice,
ItemRemark: item.remark ?? null
})),
InvType: taxType === 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: 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: 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, allowanceItems, options) {
if (invoice.taxType === 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.invoiceNumber,
InvoiceDate: DateTime.fromJSDate(invoice.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.taxType === TaxType.MIXED ? {
ItemTaxType: (()=>{
switch(options?.taxType){
case TaxType.TAXED:
return '1';
case TaxType.ZERO_TAX:
return '2';
case 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: DateTime.fromFormat(payload.IA_Date, 'yyyy-MM-dd HH:mm:ss').toJSDate(),
remainingAmount: payload.IA_Remain_Allowance_Amt,
items: allowanceItems,
parentInvoice: invoice,
status: InvoiceAllowanceState.ISSUED
});
invoice.addAllowance(allowance);
return invoice;
}
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: 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 TaxType.TAXED;
case '2':
return TaxType.ZERO_TAX;
case '3':
return TaxType.TAX_FREE;
default:
return undefined;
}
})(item.ItemTaxType),
remark: item.ItemRemark
})),
issuedOn: 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 TaxType.TAXED;
case '2':
return TaxType.ZERO_TAX;
case '3':
return TaxType.TAX_FREE;
case '4':
return TaxType.SPECIAL;
case '9':
return 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)=>new ECPayInvoice({
items: [
{
name: ECPAY_COMPRESSED_ITEM_NAME,
unitPrice: invoice.IIS_Sales_Amount,
quantity: 1,
unit: '式',
taxType: ((taxType)=>{
switch(taxType){
case '2':
return TaxType.ZERO_TAX;
case '3':
return TaxType.TAX_FREE;
case '4':
return TaxType.SPECIAL;
case '1':
default:
return TaxType.TAXED;
}
})(invoice.IIS_Tax_Type),
remark: ''
}
],
issuedOn: DateTime.fromFormat(invoice.IIS_Create_Date, 'yyyy-MM-dd HH:mm:ss').toJSDate(),
isVoid: invoice.IIS_Invalid_Status === '1',
invoiceNumber: invoice.IIS_Number,
randomCode: ECPAY_RANDOM_CODE,
orderId: invoice.IIS_Relate_Number,
awardType: Number(invoice.IIS_Award_Type),
taxType: ((taxType)=>{
switch(taxType){
case '2':
return TaxType.ZERO_TAX;
case '3':
return TaxType.TAX_FREE;
case '4':
return TaxType.SPECIAL;
case '9':
return TaxType.MIXED;
case '1':
default:
return TaxType.TAXED;
}
})(invoice.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();
}
}
export { ECPayInvoiceGateway };