Tích hợp Hóa đơn điện tử
Tài liệu này là đặc tả riêng cho ADR-005. Quyết định kiến trúc (adapter pattern, độc lập nhà cung cấp, chỉ dùng nhà cung cấp được CQT chấp thuận) được ghi lại trong architecture.md §7. Tài liệu này bao gồm phần triển khai: giao diện adapter, các nhà cung cấp được hỗ trợ, mô hình dữ liệu, cấu hình tenant, vòng đời trạng thái và xử lý lỗi.
Quyết định
Phần tiêu đề “Quyết định”Adapter pattern độc lập nhà cung cấp. Mỗi nhà cung cấp được CQT chấp thuận có adapter triển khai riêng sau giao diện IEInvoiceProvider chung. Tenant cấu hình nhà cung cấp ưa thích của họ. Nền tảng resolve adapter đúng vào runtime từ cấu hình tenant. Không có logic đặc thù của nhà cung cấp nào rò rỉ vào mã domain.
Bối cảnh
Phần tiêu đề “Bối cảnh”Pháp luật Việt Nam (Nghị định 123/2020/NĐ-CP, Thông tư 78/2021/TT-BTC) yêu cầu doanh nghiệp phải phát hành hóa đơn điện tử có chữ ký số thông qua nhà cung cấp bên thứ ba được CQT chấp thuận. CQT duy trì danh sách các nhà cung cấp đã được chấp thuận; hiện có hơn một chục đơn vị. Mỗi nhà cung cấp có API riêng để gửi, kiểm tra trạng thái, điều chỉnh và thay thế hóa đơn — nhưng tất cả đều phải tạo ra hóa đơn tuân thủ cùng định dạng pháp lý và schema XML do CQT quy định.
BPO ERP phục vụ 50–100 khách hàng hiện tại đã có hợp đồng với các nhà cung cấp khác nhau. Nền tảng không thể yêu cầu dùng một nhà cung cấp duy nhất. Adapter pattern cho phép hỗ trợ nhà cung cấp mới bằng cách thêm một lớp adapter mà không cần sửa bất kỳ logic domain hay nghiệp vụ nào.
Tóm tắt yêu cầu pháp lý
Phần tiêu đề “Tóm tắt yêu cầu pháp lý”Các yêu cầu sau là bắt buộc và phải được đáp ứng bất kể nhà cung cấp nào được sử dụng:
| Yêu cầu | Căn cứ pháp lý |
|---|---|
| Hóa đơn phải được phát hành qua nhà cung cấp được CQT chấp thuận | Nghị định 123/2020, Điều 10 |
| Hóa đơn phải chứa: MST người bán, MST người mua, ký hiệu hóa đơn, số hóa đơn, ngày phát hành, chi tiết hàng hóa/dịch vụ, đơn giá, thuế suất VAT, tiền thuế VAT, tổng tiền | Thông tư 78/2021, Phụ lục |
| Hóa đơn phải có chữ ký số của nhà cung cấp (và tùy chọn của người bán) | Nghị định 123/2020, Điều 22 |
| Khi hóa đơn đã ký có sai sót, người bán phải phát hành hóa đơn điều chỉnh để sửa các trường cụ thể, hoặc hóa đơn thay thế để hủy và phát hành lại toàn bộ. Cả hai loại phải tham chiếu số và ký hiệu hóa đơn gốc. Hóa đơn đã phát hành không thể hủy trực tiếp. | Nghị định 123/2020, Điều 19 |
| Dữ liệu hóa đơn phải được lưu trữ tối thiểu 10 năm | Luật Kế toán 2015, Điều 40 |
| Toàn bộ dữ liệu hóa đơn điện tử phải được lưu tại Việt Nam | Luật An ninh mạng 2018 |
Nền tảng quản lý vòng đời tích hợp. Nhà cung cấp thực hiện ký số và truyền lên CQT. BPO ERP chịu trách nhiệm: xây dựng payload hóa đơn đúng, gửi đến nhà cung cấp phù hợp, theo dõi trạng thái và lưu trữ kết quả.
Các nhà cung cấp được hỗ trợ
Phần tiêu đề “Các nhà cung cấp được hỗ trợ”Các nhà cung cấp sau được CQT chấp thuận và là mục tiêu triển khai adapter Phase 1. Thứ tự ưu tiên dựa trên hợp đồng của khách hàng hiện tại.
| Nhà cung cấp | Mã | Trạng thái |
|---|---|---|
| VNPT Invoice | VNPT | Phase 1 |
| Viettel e-Invoice | VIETTEL | Phase 1 |
| MISA AMIS e-Invoice | MISA | Phase 1 |
| Bkav e-Invoice | BKAV | Phase 1 |
| EasyInvoice | EASY | Phase 1 |
| MInvoice | MINVOICE | Phase 1 |
| FPT e-Invoice | FPT | Phase 2 (nếu có nhu cầu) |
Trước khi triển khai: xác nhận danh sách nhà cung cấp được CQT chấp thuận hiện tại tại https://www.gdt.gov.vn — danh sách thay đổi khi có cấp phép mới hoặc thu hồi. Adapter chỉ được xây dựng cho nhà cung cấp có trong danh sách được chấp thuận tại thời điểm tích hợp.
Để thêm nhà cung cấp mới chỉ cần: một lớp adapter mới triển khai IEInvoiceProvider và một hàng trong bảng EInvoiceProviders. Không cần thay đổi mã domain hay ứng dụng.
Giao diện Adapter
Phần tiêu đề “Giao diện Adapter”public interface IEInvoiceProvider{ /// Mã nhà cung cấp ổn định khớp với EInvoiceProviders.ProviderCode string ProviderCode { get; }
/// Gửi hóa đơn đến nhà cung cấp để ký và phát hành. /// Trả về ID hóa đơn được nhà cung cấp gán và trạng thái ban đầu. Task<IssueResult> IssueInvoiceAsync( InvoiceRequest request, TenantProviderConfig config, CancellationToken ct);
/// Kiểm tra trạng thái hiện tại của hóa đơn đã gửi trước đó. Task<StatusResult> GetInvoiceStatusAsync( string providerInvoiceId, TenantProviderConfig config, CancellationToken ct);
/// Phát hành hóa đơn thay thế thay thế hoàn toàn hóa đơn gốc đã ký. /// Hóa đơn gốc được đánh dấu Replaced; hóa đơn mới trải qua vòng đời ký bình thường. Task<ReplaceResult> ReplaceInvoiceAsync( string originalProviderInvoiceId, InvoiceRequest replacementRequest, TenantProviderConfig config, CancellationToken ct);
/// Phát hành hóa đơn điều chỉnh sửa các trường cụ thể của hóa đơn gốc đã ký. Task<AdjustResult> AdjustInvoiceAsync( string originalProviderInvoiceId, AdjustmentRequest request, TenantProviderConfig config, CancellationToken ct);}TenantProviderConfig mang thông tin xác thực đã giải mã cho tài khoản nhà cung cấp của tenant cụ thể (API key, endpoint, mã mẫu, tiền tố ký hiệu). Nó được resolve từ cơ sở dữ liệu khi bắt đầu mỗi thao tác và không bao giờ được cache lâu hơn thời gian sống của request.
InvoiceRequest mang tất cả các trường hóa đơn bắt buộc theo pháp luật ở định dạng trung lập với nhà cung cấp. Mỗi adapter ánh xạ định dạng này sang định dạng API cụ thể của nhà cung cấp. Mã domain xây dựng InvoiceRequest mà không cần biết nhà cung cấp nào sẽ nhận nó.
Mô hình dữ liệu
Phần tiêu đề “Mô hình dữ liệu”Bảng cấp nền tảng (không áp dụng RLS)
Phần tiêu đề “Bảng cấp nền tảng (không áp dụng RLS)”EInvoiceProviders────────────────────────────────────────────────────────ProviderId UNIQUEIDENTIFIER PKProviderCode NVARCHAR(20) NOT NULL UNIQUE -- 'VNPT', 'VIETTEL', v.v.ProviderName NVARCHAR(100) NOT NULLIsActive BIT NOT NULLSupportedOps NVARCHAR(MAX) NULL-- Mảng JSON các thao tác được hỗ trợ: ['Issue','Replace','Adjust','StatusPoll']-- Nhà cung cấp không hỗ trợ kiểm tra trạng thái thời gian thực (chỉ batch) được đánh dấu ở đây.Bảng có phạm vi tenant (áp dụng RLS)
Phần tiêu đề “Bảng có phạm vi tenant (áp dụng RLS)”TenantEInvoiceConfigs────────────────────────────────────────────────────────ConfigId UNIQUEIDENTIFIER PKTenantId UNIQUEIDENTIFIER NOT NULL (RLS)ProviderId UNIQUEIDENTIFIER NOT NULL FK → EInvoiceProvidersEnvironment NVARCHAR(20) NOT NULL -- 'Production' hoặc 'Sandbox'ApiEndpoint NVARCHAR(500) NOT NULLApiKey NVARCHAR(MAX) NOT NULL -- mã hóa khi lưu (column-level)TemplateCode NVARCHAR(100) NULL -- ID mẫu hóa đơn đặc thù của nhà cung cấpSerialPrefix NVARCHAR(20) NOT NULL -- ví dụ: 'C23T' theo nhà cung cấp/CQTIsDefault BIT NOT NULL -- một mặc định mỗi tenantIsActive BIT NOT NULLHỗ trợ nhiều cấu hình mỗi tenant: tenant có thể có cấu hình production và sandbox, hoặc cấu hình cho các nhà cung cấp khác nhau cho các loại chứng từ khác nhau (không phổ biến nhưng có thể).
EInvoices────────────────────────────────────────────────────────InvoiceId UNIQUEIDENTIFIER PKTenantId UNIQUEIDENTIFIER NOT NULL (RLS)SourceDocumentType NVARCHAR(50) NOT NULL -- 'CustomerInvoice', 'CreditNote'SourceDocumentId UNIQUEIDENTIFIER NOT NULL FK → bảng nguồnConfigId UNIQUEIDENTIFIER NOT NULL FK → TenantEInvoiceConfigsProviderInvoiceId NVARCHAR(200) NULL -- ID nhà cung cấp gán sau khi gửiInvoiceSerial NVARCHAR(20) NOT NULL -- ký hiệu do CQT gán (ví dụ: 'C23T')InvoiceNumber NVARCHAR(20) NULL -- số do nhà cung cấp gán khi kýInvoiceType NVARCHAR(20) NOT NULL DEFAULT 'Standard'-- 'Standard' | 'Adjustment' | 'Replacement'OriginalInvoiceId UNIQUEIDENTIFIER NULL FK → EInvoices-- set cho loại Adjustment và Replacement; tham chiếu hóa đơn đang được sửaStatus NVARCHAR(30) NOT NULL-- 'Draft' | 'Submitted' | 'Signed' | 'Issued' | 'Adjusted' | 'Replaced' | 'Failed'IssuedAt DATETIMEOFFSET NULL -- khi nhà cung cấp ký và phát hànhSubmittedAt DATETIMEOFFSET NULL -- khi nền tảng gửi đến nhà cung cấpLastPolledAt DATETIMEOFFSET NULLRetryCount INT NOT NULL DEFAULT 0ProviderResponseRaw NVARCHAR(MAX) NULL -- phản hồi thô của nhà cung cấp (JSON/XML)CreatedAt DATETIMEOFFSET NOT NULLEInvoiceStatusHistory────────────────────────────────────────────────────────HistoryId UNIQUEIDENTIFIER PKInvoiceId UNIQUEIDENTIFIER NOT NULL FK → EInvoicesTenantId UNIQUEIDENTIFIER NOT NULL (RLS)FromStatus NVARCHAR(30) NOT NULLToStatus NVARCHAR(30) NOT NULLChangedAt DATETIMEOFFSET NOT NULLNotes NVARCHAR(500) NULL -- thông báo lỗi hoặc ghi chú của nhà cung cấpVòng đời trạng thái hóa đơn
Phần tiêu đề “Vòng đời trạng thái hóa đơn” ┌─────────┐ │ Draft │ (hóa đơn nguồn đã tạo, chưa gửi) └────┬────┘ │ IssueInvoiceAsync() được gọi ▼ ┌───────────┐ │ Submitted │ (đã gửi đến nhà cung cấp; chờ ký) └─────┬─────┘ │ Nhà cung cấp ký ▼ ┌────────┐ │ Signed │ (đã ký bởi nhà cung cấp; CQT được thông báo) └───┬────┘ │ CQT xác nhận / nhà cung cấp xác nhận ▼ ┌─────────┐ │ Issued │ ◄─── trạng thái thành công cuối cùng └────┬────┘ │ ┌────────┴────────┐ │ │ ▼ ▼┌──────────┐ ┌──────────┐│ Replaced │ │ Adjusted │└──────────┘ └──────────┘ (cả hai đều là trạng thái cuối cùng của hóa đơn gốc; hóa đơn thay thế/điều chỉnh là một hàng EInvoices mới với vòng đời Draft → Issued riêng)
Failed ◄── bất kỳ chuyển đổi nào cũng có thể dẫn đến đây khi có lỗi nhà cung cấp không thể phục hồiCác chuyển đổi được ghi lại trong EInvoiceStatusHistory. Cột EInvoices.Status phản ánh trạng thái hiện tại. Trạng thái chỉ được cập nhật thông qua EInvoiceService — không bao giờ trực tiếp.
Lựa chọn nhà cung cấp
Phần tiêu đề “Lựa chọn nhà cung cấp”TenantEInvoiceConfig mặc định của tenant được sử dụng trừ khi bị ghi đè. Thứ tự resolve:
- Nếu loại chứng từ có ánh xạ cấu hình rõ ràng (tương lai), dùng cấu hình đó.
- Nếu không, dùng cấu hình
IsDefault = 1của tenant. - Nếu không có cấu hình active, thao tác phát hành thất bại với lỗi
NoProviderConfigured— hiển thị cho người dùng trong UI trước khi thực hiện gửi.
Trường Environment trong cấu hình kiểm soát việc dùng thông tin xác thực sandbox hay production. Môi trường sandbox dùng trong quá trình onboard tenant và kiểm thử; chuyển sang production chỉ cần thay đổi một cài đặt cấu hình, không liên quan đến code.
Luồng gửi hóa đơn
Phần tiêu đề “Luồng gửi hóa đơn”CustomerInvoiceService.IssueEInvoice(invoiceId) │ ├─ Tải CustomerInvoice từ DB ├─ Xác thực hóa đơn ở trạng thái có thể gửi (đã xác nhận, chưa phát hành) ├─ Resolve TenantEInvoiceConfig (mặc định hoặc rõ ràng) ├─ Xây dựng InvoiceRequest (định dạng trung lập nhà cung cấp) ├─ Resolve IEInvoiceProvider từ DI container theo ProviderCode ├─ Tạo hàng EInvoices với Status = 'Draft' │ ├─ Gọi provider.IssueInvoiceAsync(request, config) │ ├─ Thành công → cập nhật Status = 'Submitted', lưu ProviderInvoiceId │ └─ Thất bại → cập nhật Status = 'Failed', lưu lỗi, publish sự kiện InvoiceIssueFailed │ └─ Background job kiểm tra trạng thái tiếp quản cho các nhà cung cấp bất đồng bộViệc gửi không được tự động thử lại khi thất bại — domain service hiển thị lỗi cho người dùng để họ có thể thử lại thủ công sau khi sửa dữ liệu. Lỗi mạng tạm thời (timeout, 503) được thử lại với backoff theo cấp số nhân trong adapter, tối đa 3 lần, trước khi trả về thất bại cho domain service.
Kiểm tra trạng thái
Phần tiêu đề “Kiểm tra trạng thái”Một số nhà cung cấp xác nhận ký hóa đơn bất đồng bộ. Background job kiểm tra các hóa đơn đang chờ:
- Tần suất: mỗi 5 phút cho các hóa đơn ở trạng thái
Submitted - Số lần thử tối đa: 48 giờ kiểm tra (576 lần ở khoảng 5 phút), sau đó chuyển sang
Failedvới ghi chúProviderTimeout - Khi thay đổi trạng thái: cập nhật
EInvoices.Status, thêm vàoEInvoiceStatusHistory, publish sự kiện domainEInvoiceStatusChanged - Khi
Issued: kích hoạt sự kiện kế toánCustomerInvoiceIssued(nếu chưa được kích hoạt)
Hóa đơn thay thế và điều chỉnh
Phần tiêu đề “Hóa đơn thay thế và điều chỉnh”Hóa đơn đã phát hành ở Việt Nam không thể hủy trực tiếp. Sai sót được sửa bằng cách phát hành hóa đơn mới tham chiếu đến hóa đơn gốc. Nghị định 123/2020 Điều 19 định nghĩa hai loại:
Hóa đơn thay thế được dùng khi hóa đơn gốc có sai sót cần một chứng từ hoàn toàn mới (ví dụ: sai người mua, sai hàng hóa, sai loại hóa đơn). Hóa đơn thay thế tạo ra một hàng EInvoices mới với InvoiceType = 'Replacement' và OriginalInvoiceId trỏ đến hóa đơn gốc. Hóa đơn thay thế trải qua vòng đời gửi tiêu chuẩn (Draft → Submitted → Signed → Issued). Khi hóa đơn thay thế đạt Issued, hóa đơn gốc được đánh dấu Replaced và trở thành bất biến.
Hóa đơn điều chỉnh được dùng khi chỉ cần sửa các trường cụ thể (ví dụ: số lượng, đơn giá, tiền thuế VAT). Hóa đơn điều chỉnh tạo ra một hàng EInvoices mới với InvoiceType = 'Adjustment' và OriginalInvoiceId trỏ đến hóa đơn gốc. Khi đã phát hành, hóa đơn gốc được đánh dấu Adjusted và trở thành bất biến. Adapter phải tuân thủ các quy định đặc thù của từng nhà cung cấp về định dạng điều chỉnh, khác nhau giữa các nhà cung cấp.
Xử lý lỗi
Phần tiêu đề “Xử lý lỗi”| Loại lỗi | Xử lý |
|---|---|
| Timeout API nhà cung cấp (<30 giây) | Thử lại trong adapter (tối đa 3 lần, backoff theo cấp số nhân), sau đó trả về thất bại |
| Nhà cung cấp trả về lỗi validation (ví dụ: MST không hợp lệ) | Hiển thị lỗi cho người dùng; không thử lại; sửa dữ liệu và gửi lại thủ công |
| Nhà cung cấp trả về lỗi xác thực (API key không hợp lệ) | Cảnh báo quản trị viên tenant; tạm dừng gửi cho đến khi sửa cấu hình |
| Nhà cung cấp không khả dụng (5xx) | Thử lại trong adapter; nếu hết lần thử, hiển thị thất bại; background job sẽ thử lại qua kiểm tra trạng thái |
| Hóa đơn đã gửi (trùng lặp) | Adapter kiểm tra ProviderInvoiceId trước khi gửi; ghi log và hiển thị cảnh báo |
| CQT từ chối sau khi ký | Ghi nhận qua kiểm tra trạng thái; chuyển sang Failed; thông báo người dùng với mã lỗi CQT |
Tất cả lỗi được lưu trong EInvoiceStatusHistory.Notes và phản hồi thô của nhà cung cấp trong EInvoices.ProviderResponseRaw để debug.
Cân nhắc bảo mật
Phần tiêu đề “Cân nhắc bảo mật”- API key trong
TenantEInvoiceConfigs.ApiKeyđược mã hóa khi lưu bằng SQL Server column-level encryption. Chúng chỉ được giải mã trong tiến trình server, không bao giờ trả về cho client hay ghi log. - Các lệnh gọi API nhà cung cấp sử dụng TLS 1.2+ và xác thực chứng chỉ nhà cung cấp.
- Thông tin xác thực sandbox không bao giờ được dùng trong môi trường production — trường
Environmentđược thực thi trong adapter. - Dịch vụ gửi hóa đơn điện tử chạy dưới danh tính người dùng tenant để ghi nhật ký kiểm toán, nhưng API key của nhà cung cấp không lấy từ JWT của người dùng — nó chỉ được tải từ cấu hình phía server.
Vấn đề còn mở
Phần tiêu đề “Vấn đề còn mở”-
Hợp đồng API nhà cung cấp — API của từng nhà cung cấp phải được xem xét và ánh xạ sang
InvoiceRequesttrước khi xây dựng adapter. Tài liệu API cho VNPT, Viettel, MISA và FPT phải được lấy trực tiếp từ các nhà cung cấp. -
Quản lý dải số hóa đơn — CQT cấp dải số hóa đơn cho doanh nghiệp. Quy trình đăng ký số hóa đơn và quản lý dải theo tenant cần được thiết kế (có thể là quy trình quản trị viên tenant).
-
Hóa đơn B2C và B2B — Yêu cầu pháp lý có khác biệt nhỏ giữa hóa đơn cho người tiêu dùng và doanh nghiệp (ví dụ: MST người mua là tùy chọn cho B2C dưới ngưỡng nhất định). Mô hình
InvoiceRequestcần có cờ và các adapter phải xử lý điều này. -
Quản lý mẫu hóa đơn đặc thù nhà cung cấp — Một số nhà cung cấp yêu cầu tenant đăng ký trước mẫu hóa đơn. Trường
TemplateCodetrongTenantEInvoiceConfigsgiải quyết điều này, nhưng luồng UI onboarding cần được thiết kế. -
Gửi nhiều hóa đơn cùng lúc — Một số nhà cung cấp hỗ trợ gửi batch để tăng hiệu suất. Điều này được hoãn cho đến khi gửi đơn lẻ ổn định.