Skip to content

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ấpLoại APITrạng thái
VNPTREST/SOAPĐược hỗ trợ
ViettelRESTĐược hỗ trợ
BKAVRESTĐược hỗ trợ
FPTRESTĐược hỗ trợ
MISARESTĐã 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ô tảHành động
00Thành côngHóa đơn được chấp nhận
01Chữ 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
03Số hóa đơn trùng lặpSử dụng số mới
04Mã số thuế không hợp lệXác minh mã số thuế
05Chứng thư hết hạnSử dụng chứng thư hợp lệ
06Mẫu không hợp lệĐăng ký mẫu trước
07Người bán chưa đăng kýHoàn tất đăng ký
08Dịch vụ không khả dụngThử lại sau

Mã Phản hồi GDT

Mô tả
1Đã duyệt - Đã cấp mã CQT
2Từ chối - Dữ liệu không hợp lệ
3Từ chối - Hóa đơn trùng lặp
4Từ 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ếnMô tả
TVAN_PROVIDERTên nhà cung cấp (VNPT, VIETTEL, BKAV, FPT)
TVAN_API_URLĐiểm cuối API nhà cung cấp
TVAN_API_KEYKhóa xác thực API
TVAN_SECRET_KEYKhóa bí mật để ký
TVAN_WEBHOOK_SECRETXác minh chữ ký webhook
TVAN_TIMEOUTThờ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;
}

Tài liệu Liên quan

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.