@rytass/payments-adapter-ecpay
v0.4.13
Published
Rytass Payment Gateway
Downloads
733
Readme
Rytass Utils - Payments (ECPay)
Features
- [x] Built-in callback server
- [x] Checkout (Credit Card)
- [x] Checkout (Credit Card Installments)
- [ ] Checkout (WebATM)
- [x] Checkout (ATM/Virtual Account)
- [x] Checkout (CVS)
- [x] Checkout (Barcode)
- [x] Checkout (Apple Pay)
- [ ] Checkout (Line Pay)
- [x] Query
- [ ] Refund
- [ ] Refund (Installments)
- [x] Card Binding
- [x] Ticket Issue (ECTicket)
- [x] Query Ticket Issue Result
- [x] Query Ticket Order Info
Getting Started
Credit Card Payment
NOTICE: Please use NAT tunnel service (like ngrok) to proxy built-in server if you are behind a LAN network.
import { Channel, ECPayChannelCreditCard, ECPayPayment } from '@rytass/payments-adapter-ecpay';
// Use built-in server
const MERCHANT_ID = 'YOUR_ECPAY_MERCHANT_ID';
const HASH_KEY = 'YOUR_ECPAY_HASH_KEY';
const HASH_IV = 'YOUR_ECPAY_HASH_IV';
function onOrderCommit(order: ECPayOrder<ECPayChannelCreditCard>) {
// When order committed, you can check amount, transaction code....
}
const payment = new ECPayPayment<ECPayChannelCreditCard>({
merchantId: MERCHANT_ID,
hashKey: HASH_KEY,
hashIv: HASH_IV,
serverHost: 'http://localhost:3000', // Built-in server listens on localhost:3000 or ngrok url
onCommit: onOrderCommit,
withServer: true,
});
// Order ID can be auto-assigned or provided from `id` argument
const order = payment.prepare({
channel: Channel.CREDIT_CARD,
items: [
{
name: 'Book',
unitPrice: 200,
quantity: 1,
},
{
name: '鉛筆',
unitPrice: 15,
quantity: 2,
},
],
});
// You have three ways to pre-commit order
// 1. Get form data to prepare POST form by yourself
const form = order.form;
// 2. Get HTML including form data and automatic submit script
const html = order.formHTML;
// 3. Get built-in server URL to auto-submit (only works if `withServer` is set)
const url = order.checkoutURL;Bind Card With Transaction
const payment = new ECPayPayment<ECPayChannelCreditCard>({
merchantId: MERCHANT_ID,
hashKey: HASH_KEY,
hashIv: HASH_IV,
serverHost: 'http://localhost:3000', // Built-in server listens on localhost:3000 or ngrok url
onCommit: onOrderCommit,
withServer: false,
// when memory is true, you cannot use this transaction to bind card
memory: false,
});
// get the platform
function onOrderCommit(order: ECPayOrder<ECPayChannelCreditCard>) {
// When order committed, you can bind card with transaction
if (ecpayOrder.state !== OrderState.COMMITTED) {
const { id, platformTradeNumber } = order;
const memberId = 'MEMBER_ID';
const request = await ecPayPayment.bindCardWithTransaction(memberId, platformTradeNumber, id);
// You can save cardId to database
const cardId = request.cardId;
// You can use cardId to checkout with bound card
const result = await this.ecPayPayment.checkoutWithBoundCard({
memberId,
cardId,
description: 'test',
amount: 100,
});
}
}Handle Card Already Bound
const payment = new ECPayPayment();
payment.emitter.on(PaymentEvents.CARD_BINDING_FAILED, (request: ECPayBindCardRequest) => {
// Card already bound
if (request.failedMessage?.code === '10100112') {
console.log(`memberId: ${request.memberId}`);
console.log(`cardId: ${request.cardId}`);
console.log(`cardNumberPrefix: ${request.cardNumberPrefix}`);
console.log(`cardNumberSuffix: ${request.cardNumberSuffix}`);
}
});ECTicket (票券) APIs
ECPayTicketGateway is an independent gateway for the ECPay ECTicket product line (issuing, querying, refund/redeem notifications for redemption and gift tickets). It uses a different base URL (ecticket.ecpay.com.tw) and wire format (AES-128-CBC encrypted JSON envelope with a CheckMacValue) than ECPayPayment, so it is exposed as a separate class. It shares the same HashKey / HashIV credentials with the payment gateway.
Setup
import {
ECPayTicketGateway,
ECPayTicketBaseUrls,
ECPayTicketEvents,
ECPayIssueType,
ECPayPrintType,
ECPayIsImmediate,
} from '@rytass/payments-adapter-ecpay';
const ticket = new ECPayTicketGateway({
merchantId: process.env.ECPAY_MERCHANT_ID!,
hashKey: process.env.ECPAY_HASH_KEY!,
hashIv: process.env.ECPAY_HASH_IV!,
baseUrl: ECPayTicketBaseUrls.PRODUCTION, // omit for staging
// Optional: built-in callback server for RefundNotifyURL / UseStatusNotifyURL
withServer: true,
serverHost: 'https://your-domain.com',
// Optional: tune background polling for issuance result
issuePoll: {
intervalMs: 30_000, // default 30s
timeoutMs: 6 * 60_000, // default 6min (ECPay claims completion within 5min)
},
// Or disable background polling entirely:
// issuePoll: false, // issue() will not emit TICKET_ISSUED / TICKET_ISSUE_FAILED;
// you are expected to call queryIssueResult() yourself.
// `waitForIssuance: true` still works on a per-call basis.
});
ticket.emitter.on(ECPayTicketEvents.SERVER_LISTENED, ({ url }) => {
console.log('Ticket callback server ready at', url);
});Issue Tickets
After ECPay receives the issue request, the actual issuance is processed asynchronously (typically within 5 minutes). Two usage modes are supported:
Mode A — return receipt immediately, listen for the final result via events:
ticket.emitter.on(ECPayTicketEvents.TICKET_ISSUED, outcome => {
// outcome: { status: 'success', merchantTradeNo, freeTradeNo? }
console.log('Issued:', outcome.merchantTradeNo);
});
ticket.emitter.on(ECPayTicketEvents.TICKET_ISSUE_FAILED, outcome => {
// outcome: { status: 'failed', merchantTradeNo, remark }
console.error('Issue failed:', outcome.remark);
});
const receipt = await ticket.issue({
merchantTradeNo: 'ORDER-2026-0001',
issueType: ECPayIssueType.PAPER, // CVS / PAPER / ELECTRONIC / SERIAL_ONLY
printType: ECPayPrintType.ECPAY, // required when issueType is PAPER
operator: 'system',
customer: {
name: '王小明',
phone: '0912345678',
email: '[email protected]',
},
tickets: [
{ itemNo: 'I1', itemName: '咖啡兌換券', ticketPrice: 150, ticketAmount: 10 },
],
});
console.log(receipt.ticketTradeNo); // ECPay-assigned trade numberMode B — await final outcome (waitForIssuance: true):
const outcome = await ticket.issue({
merchantTradeNo: 'ORDER-2026-0002',
issueType: ECPayIssueType.ELECTRONIC,
isImmediate: ECPayIsImmediate.IMMEDIATE,
operator: 'system',
customer: { name: '王小明', phone: '0912345678', email: '[email protected]' },
tickets: [{ itemNo: 'I1', ticketAmount: 1 }],
waitForIssuance: true,
});
if (outcome.status === 'success') {
// proceed with fulfilment
} else if (outcome.status === 'failed') {
console.error(outcome.remark);
}Query Issue Result (manual)
const outcome = await ticket.queryIssueResult({ merchantTradeNo: 'ORDER-2026-0001' });
switch (outcome.status) {
case 'success':
// tickets are ready
break;
case 'processing':
// still in queue
break;
case 'failed':
console.error(outcome.remark);
break;
}Query Order Info (with ticket list)
const info = await ticket.queryOrderInfo({
merchantTradeNo: 'ORDER-2026-0001',
pageNum: 1, // optional, 200 tickets per page
});
console.log(info.totalCount, info.tradeAmount);
console.log(info.redeemCount, info.refundCount, info.unUsedCount);
info.tickets.forEach(t => {
console.log(t.ticketNo, t.useStatus); // 'unused' | 'redeemed' | 'refunded' | 'expired'
});Refund / Use-status Notifications
When withServer: true, the gateway mounts two callback endpoints and emits events whenever ECPay pushes a notification. Pass the notify URLs to issue() either explicitly via refundNotifyUrl / useStatusNotifyUrl, or let the gateway auto-fill them from serverHost.
ticket.emitter.on(ECPayTicketEvents.TICKET_REFUND_NOTIFIED, notification => {
console.log('Refunded:', notification.ticketTradeNo, notification.refundAmount);
});
ticket.emitter.on(ECPayTicketEvents.TICKET_USE_STATUS_CHANGED, notification => {
console.log('Use status changed:', notification.ticketNo, notification.useStatus);
});Default callback paths (overridable via refundNotifyPath / useStatusNotifyPath options):
POST /payments/ecpay/ticket/refundPOST /payments/ecpay/ticket/use-status
Each callback is verified against its CheckMacValue before the event fires; invalid envelopes are rejected with 400 0|InvalidCheckMacValue and do not emit.
Receiving Callbacks Without the Built-in Server
If you already run an HTTP framework (Express, NestJS, Fastify, etc.) and prefer it to receive the notifications, pass the parsed JSON envelope to handleRefundNotification() / handleUseStatusNotification(). Both verify CheckMacValue, decrypt Data, emit the corresponding event, and return the typed notification. They throw ECPayTicketCallbackError on invalid envelopes.
import {
ECPayTicketGateway,
ECPayTicketCallbackError,
ECPayTicketResponseEnvelope,
} from '@rytass/payments-adapter-ecpay';
const ticket = new ECPayTicketGateway({
merchantId: process.env.ECPAY_MERCHANT_ID!,
hashKey: process.env.ECPAY_HASH_KEY!,
hashIv: process.env.ECPAY_HASH_IV!,
// No withServer — your framework handles HTTP
});
// Pass these URLs to ECPay via the issue() input
const refundNotifyUrl = 'https://your-app.com/ecpay/ticket/refund';
const useStatusNotifyUrl = 'https://your-app.com/ecpay/ticket/use-status';
// === Express ===
import express from 'express';
const app = express();
app.post('/ecpay/ticket/refund', express.json(), (req, res) => {
try {
const notification = ticket.handleRefundNotification(req.body as ECPayTicketResponseEnvelope);
// notification is also emitted via ticket.emitter
res.type('text/plain').send('1|OK');
} catch (err) {
if (err instanceof ECPayTicketCallbackError) {
res.status(400).type('text/plain').send(`0|${err.code}`);
return;
}
res.status(500).type('text/plain').send('0|InternalError');
}
});
app.post('/ecpay/ticket/use-status', express.json(), (req, res) => {
try {
ticket.handleUseStatusNotification(req.body as ECPayTicketResponseEnvelope);
res.type('text/plain').send('1|OK');
} catch (err) {
res.status(400).type('text/plain').send('0|Invalid');
}
});
The event listeners registered on ticket.emitter fire identically regardless of whether the notification arrived via the built-in server or via these framework-agnostic handlers — pick whichever transport fits your stack.
NestJS Integration
A complete NestJS setup splits the concerns into three pieces: a module that constructs the gateway from ConfigService, a controller that turns ECPay's POSTs into typed events, and a service that subscribes to those events on startup. None of them depend on Node's raw http types.
// ecpay-ticket.constants.ts
export const ECPAY_TICKET_GATEWAY = Symbol('ECPAY_TICKET_GATEWAY');// ecpay-ticket.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ECPayTicketBaseUrls, ECPayTicketGateway } from '@rytass/payments-adapter-ecpay';
import { ECPAY_TICKET_GATEWAY } from './ecpay-ticket.constants';
import { EcpayTicketController } from './ecpay-ticket.controller';
import { EcpayTicketEventService } from './ecpay-ticket-event.service';
@Module({
imports: [ConfigModule],
providers: [
{
provide: ECPAY_TICKET_GATEWAY,
useFactory: (config: ConfigService): ECPayTicketGateway =>
new ECPayTicketGateway({
merchantId: config.getOrThrow<string>('ECPAY_MERCHANT_ID'),
hashKey: config.getOrThrow<string>('ECPAY_HASH_KEY'),
hashIv: config.getOrThrow<string>('ECPAY_HASH_IV'),
baseUrl:
config.get<string>('NODE_ENV') === 'production'
? ECPayTicketBaseUrls.PRODUCTION
: ECPayTicketBaseUrls.DEVELOPMENT,
// Do NOT enable the built-in server — Nest's HTTP layer receives the callbacks.
}),
inject: [ConfigService],
},
EcpayTicketEventService,
],
controllers: [EcpayTicketController],
exports: [ECPAY_TICKET_GATEWAY],
})
export class EcpayTicketModule {}// ecpay-ticket.controller.ts
import {
Body,
Controller,
Header,
HttpCode,
HttpException,
HttpStatus,
Inject,
Logger,
Post,
} from '@nestjs/common';
import {
ECPayTicketCallbackError,
ECPayTicketGateway,
ECPayTicketResponseEnvelope,
} from '@rytass/payments-adapter-ecpay';
import { ECPAY_TICKET_GATEWAY } from './ecpay-ticket.constants';
@Controller('ecpay/ticket')
export class EcpayTicketController {
private readonly logger = new Logger(EcpayTicketController.name);
constructor(@Inject(ECPAY_TICKET_GATEWAY) private readonly ticket: ECPayTicketGateway) {}
@Post('refund')
@HttpCode(HttpStatus.OK)
@Header('Content-Type', 'text/plain')
handleRefund(@Body() envelope: ECPayTicketResponseEnvelope): string {
try {
const notification = this.ticket.handleRefundNotification(envelope);
this.logger.log(`Refund accepted: ${notification.ticketTradeNo}`);
return '1|OK';
} catch (err) {
return this.toErrorResponse(err, 'refund');
}
}
@Post('use-status')
@HttpCode(HttpStatus.OK)
@Header('Content-Type', 'text/plain')
handleUseStatus(@Body() envelope: ECPayTicketResponseEnvelope): string {
try {
const notification = this.ticket.handleUseStatusNotification(envelope);
this.logger.log(`Use status: ${notification.ticketNo} → ${notification.useStatus}`);
return '1|OK';
} catch (err) {
return this.toErrorResponse(err, 'use-status');
}
}
private toErrorResponse(err: unknown, scope: string): never {
if (err instanceof ECPayTicketCallbackError) {
this.logger.warn(`Rejected ${scope} callback: ${err.code}`);
throw new HttpException(
`0|${err.code === 'INVALID_CHECKMAC' ? 'InvalidCheckMacValue' : 'InvalidData'}`,
HttpStatus.BAD_REQUEST,
);
}
this.logger.error(`Unexpected ${scope} callback error`, err);
throw new HttpException('0|InternalError', HttpStatus.INTERNAL_SERVER_ERROR);
}
}// ecpay-ticket-event.service.ts
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import {
ECPayTicketEvents,
ECPayTicketGateway,
ECPayTicketRefundNotification,
ECPayTicketUseStatusNotification,
} from '@rytass/payments-adapter-ecpay';
import { ECPAY_TICKET_GATEWAY } from './ecpay-ticket.constants';
@Injectable()
export class EcpayTicketEventService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EcpayTicketEventService.name);
private readonly onRefund = (n: ECPayTicketRefundNotification): void => {
// Persist refund, notify customer, etc.
this.logger.log(`Refund received: ${n.ticketTradeNo} amount=${n.refundAmount}`);
};
private readonly onUseStatus = (n: ECPayTicketUseStatusNotification): void => {
// Update ticket usage state in your DB
this.logger.log(`Ticket ${n.ticketNo} → ${n.useStatus}`);
};
constructor(@Inject(ECPAY_TICKET_GATEWAY) private readonly ticket: ECPayTicketGateway) {}
onModuleInit(): void {
this.ticket.emitter.on(ECPayTicketEvents.TICKET_REFUND_NOTIFIED, this.onRefund);
this.ticket.emitter.on(ECPayTicketEvents.TICKET_USE_STATUS_CHANGED, this.onUseStatus);
}
onModuleDestroy(): void {
this.ticket.emitter.off(ECPayTicketEvents.TICKET_REFUND_NOTIFIED, this.onRefund);
this.ticket.emitter.off(ECPayTicketEvents.TICKET_USE_STATUS_CHANGED, this.onUseStatus);
}
}Wiring the notify URLs. When you call ticket.issue(), pass refundNotifyUrl and useStatusNotifyUrl pointing at the Nest controller routes (e.g. https://your-domain.com/ecpay/ticket/refund). Nest's @Body() decorator parses the JSON for you, so the controller methods receive the typed envelope directly — no body-parser boilerplate.
Why OnModuleDestroy. The event listeners are bound in onModuleInit and explicitly removed in onModuleDestroy so reloading the module (e.g. in tests or with HMR) does not leak duplicate listeners on the long-lived gateway emitter.
