Tích hợp T-VAN
Tổng quan
Các nhà cung cấp T-VAN (Mạng giá trị gia tăng về thuế) đóng vai trò trung gian giữa doanh nghiệp và Tổng cục Thuế (GDT). Họ nhận hóa đơn điện tử đã ký, xác thực chúng và truyền đến cơ quan thuế.
Các Nhà cung cấp T-VAN
| Nhà cung cấp | Loại API | Trạng thái |
|---|---|---|
| VNPT | REST/SOAP | Được hỗ trợ |
| Viettel | REST | Được hỗ trợ |
| BKAV | REST | Được hỗ trợ |
| FPT | REST | Được hỗ trợ |
| MISA | REST | Đã lên kế hoạch |
Luồng Tích hợp
Yêu cầu/Phản hồi T-VAN
Gửi Hóa đơn
http
POST /api/v1/invoices/submit
Content-Type: application/json
Authorization: Bearer <api_key>
{
"merchantId": "merchant-123",
"invoiceXml": "<HDon>...signed XML...</HDon>",
"invoiceNumber": "C25TAA/00000001",
"issueDate": "2025-01-20"
}Phản hồi
json
{
"success": true,
"data": {
"submissionId": "sub-abc-123",
"status": "SUBMITTED",
"timestamp": "2025-01-20T10:00:00Z",
"estimatedProcessingTime": 300
}
}Truy vấn Trạng thái
http
GET /api/v1/invoices/status/{submissionId}
Authorization: Bearer <api_key>json
{
"submissionId": "sub-abc-123",
"status": "AUTHORIZED",
"cqtCode": "ABC123DEF456",
"processedAt": "2025-01-20T10:05:00Z"
}Webhook Callbacks
Payload Webhook
typescript
interface TVanWebhook {
event: 'INVOICE_AUTHORIZED' | 'INVOICE_REJECTED';
timestamp: string;
data: {
submissionId: string;
invoiceNumber: string;
cqtCode?: string; // Cho AUTHORIZED
rejectionCode?: string; // Cho REJECTED
rejectionReason?: string;
};
signature: string; // Chữ ký HMAC để xác minh
}Xác minh Webhook
typescript
function verifyWebhook(payload: string, signature: string): boolean {
const expectedSignature = crypto
.createHmac('sha256', process.env.TVAN_WEBHOOK_SECRET)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}Trình xử lý Webhook
typescript
app.post('/webhooks/tvan', async (c) => {
const payload = await c.req.text();
const signature = c.req.header('X-Webhook-Signature');
// Xác minh chữ ký
if (!verifyWebhook(payload, signature)) {
return c.json({ error: 'Chữ ký không hợp lệ' }, 401);
}
const webhook: TVanWebhook = JSON.parse(payload);
switch (webhook.event) {
case 'INVOICE_AUTHORIZED':
await invoiceService.markAuthorized({
submissionId: webhook.data.submissionId,
cqtCode: webhook.data.cqtCode,
});
break;
case 'INVOICE_REJECTED':
await invoiceService.markRejected({
submissionId: webhook.data.submissionId,
reason: webhook.data.rejectionReason,
});
break;
}
return c.json({ received: true });
});TVanIntegrationService
typescript
@injectable()
class TVanIntegrationService {
constructor(
private httpClient: HttpClient,
private config: TVanConfig,
) {}
/**
* Gửi hóa đơn đã ký đến T-VAN
*/
async submitInvoice(opts: {
merchantId: string;
signedXml: string;
invoiceNumber: string;
issueDate: Date;
}): Promise<SubmissionResult> {
const response = await this.httpClient.post(
`${this.config.apiUrl}/invoices/submit`,
{
merchantId: opts.merchantId,
invoiceXml: opts.signedXml,
invoiceNumber: opts.invoiceNumber,
issueDate: opts.issueDate.toISOString(),
},
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return {
submissionId: response.data.submissionId,
status: response.data.status,
};
}
/**
* Truy vấn trạng thái gửi
*/
async queryStatus(submissionId: string): Promise<SubmissionStatus> {
const response = await this.httpClient.get(
`${this.config.apiUrl}/invoices/status/${submissionId}`,
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return {
status: response.data.status,
cqtCode: response.data.cqtCode,
rejectionReason: response.data.rejectionReason,
};
}
/**
* Hủy hóa đơn đã gửi
*/
async cancelInvoice(opts: {
submissionId: string;
reason: string;
}): Promise<CancelResult> {
const response = await this.httpClient.post(
`${this.config.apiUrl}/invoices/cancel`,
{
submissionId: opts.submissionId,
reason: opts.reason,
},
{
headers: {
Authorization: `Bearer ${this.config.apiKey}`,
},
},
);
return response.data;
}
}Mã Phản hồi
Mã Phản hồi T-VAN
| Mã | Mô tả | Hành động |
|---|---|---|
| 00 | Thành công | Hóa đơn được chấp nhận |
| 01 | Chữ ký không hợp lệ | Ký lại và thử lại |
| 02 | Định dạng XML không hợp lệ | Sửa XML và thử lại |
| 03 | Số hóa đơn trùng lặp | Sử dụng số mới |
| 04 | Mã số thuế không hợp lệ | Xác minh mã số thuế |
| 05 | Chứng thư hết hạn | Sử dụng chứng thư hợp lệ |
| 06 | Mẫu không hợp lệ | Đăng ký mẫu trước |
| 07 | Người bán chưa đăng ký | Hoàn tất đăng ký |
| 08 | Dịch vụ không khả dụng | Thử lại sau |
Mã Phản hồi GDT
| Mã | Mô tả |
|---|---|
| 1 | Đã duyệt - Đã cấp mã CQT |
| 2 | Từ chối - Dữ liệu không hợp lệ |
| 3 | Từ chối - Hóa đơn trùng lặp |
| 4 | Từ chối - Mẫu không khớp |
| 5 | Đang chờ xem xét |
Cơ chế Thử lại
Cấu hình Thử lại
typescript
const retryConfig = {
maxRetries: 3,
initialDelay: 1000, // 1 giây
maxDelay: 30000, // 30 giây
backoffMultiplier: 2,
retryableErrors: [
'NETWORK_ERROR',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'SERVICE_UNAVAILABLE',
],
nonRetryableErrors: [
'INVALID_SIGNATURE',
'INVALID_XML',
'DUPLICATE_INVOICE',
'CERTIFICATE_EXPIRED',
],
};Triển khai Thử lại
typescript
async function submitWithRetry(
invoice: Invoice,
config: RetryConfig,
): Promise<SubmissionResult> {
let lastError: Error;
let delay = config.initialDelay;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await tvanService.submitInvoice(invoice);
} catch (error) {
lastError = error;
// Kiểm tra nếu lỗi có thể thử lại
if (config.nonRetryableErrors.includes(error.code)) {
throw error;
}
if (attempt === config.maxRetries) {
break;
}
// Chờ trước khi thử lại
await sleep(delay);
delay = Math.min(delay * config.backoffMultiplier, config.maxDelay);
}
}
throw new MaxRetriesExceededError(
`Thất bại sau ${config.maxRetries} lần thử`,
lastError,
);
}Polling trạng thái
Đối với các nhà cung cấp không hỗ trợ webhook:
typescript
async function pollForStatus(
submissionId: string,
timeout: number = 300000, // 5 phút
): Promise<SubmissionStatus> {
const startTime = Date.now();
let interval = 5000; // Bắt đầu với 5 giây
while (Date.now() - startTime < timeout) {
const status = await tvanService.queryStatus(submissionId);
if (status.status === 'AUTHORIZED' || status.status === 'REJECTED') {
return status;
}
await sleep(interval);
interval = Math.min(interval * 1.5, 30000); // Tối đa 30 giây
}
throw new TimeoutError('Hết thời gian polling trạng thái');
}Cấu hình Riêng cho Nhà cung cấp
VNPT T-VAN
typescript
const vnptConfig: TVanConfig = {
provider: 'VNPT',
apiUrl: 'https://api.vnpt-invoice.com.vn/v1',
apiKey: process.env.VNPT_API_KEY,
secretKey: process.env.VNPT_SECRET_KEY,
webhookSecret: process.env.VNPT_WEBHOOK_SECRET,
};Viettel T-VAN
typescript
const viettelConfig: TVanConfig = {
provider: 'VIETTEL',
apiUrl: 'https://api.sinvoice.viettel.vn/v1',
apiKey: process.env.VIETTEL_API_KEY,
secretKey: process.env.VIETTEL_SECRET_KEY,
webhookSecret: process.env.VIETTEL_WEBHOOK_SECRET,
};Biến Môi trường
| Biến | Mô tả |
|---|---|
TVAN_PROVIDER | Tên nhà cung cấp (VNPT, VIETTEL, BKAV, FPT) |
TVAN_API_URL | Điểm cuối API nhà cung cấp |
TVAN_API_KEY | Khóa xác thực API |
TVAN_SECRET_KEY | Khóa bí mật để ký |
TVAN_WEBHOOK_SECRET | Xác minh chữ ký webhook |
TVAN_TIMEOUT | Thời gian chờ yêu cầu (ms) |
Xử lý Lỗi
typescript
class TVanError extends Error {
constructor(
message: string,
public code: string,
public submissionId?: string,
public retryable: boolean = false,
) {
super(message);
this.name = 'TVanError';
}
}
// Sử dụng
try {
await tvanService.submitInvoice(invoice);
} catch (error) {
if (error instanceof TVanError) {
if (error.retryable) {
await scheduleRetry(invoice, error);
} else {
await markFailed(invoice, error);
}
}
throw error;
}