Invoice Numbering
Overview
Vietnamese e-invoices require sequential numbering within each invoice template. Numbers must be consecutive without gaps, and any cancelled numbers must be recorded properly.
Number Format
[Template Symbol]/[Sequential Number]
Example: C25TAA/00000001
C25TAA = Template Symbol (assigned by merchant)
00000001 = Sequential number (8 digits, zero-padded)Numbering Rules
| Rule | Description |
|---|---|
| Sequential | Numbers must be consecutive (1, 2, 3...) |
| No Gaps | Gaps require cancellation records |
| Per Template | Separate sequence per invoice template |
| Zero-Padded | Numbers are 8 digits with leading zeros |
| Reset | Can optionally reset annually |
Invoice Template
Template Symbol Format
[Position 1][Year 2-digit][Template Type][Sequence Letter]
Position 1: C = Issued by business
T = Generated by tax authority
Year: 25 = 2025
Type: T = E-Invoice
G = VAT Invoice
Sequence: AA, AB, AC... (merchant-defined)
Example: C25TAA
- C: Business-issued
- 25: Year 2025
- T: E-Invoice type
- AA: First templateTemplate Configuration
typescript
interface InvoiceTemplate {
id: string;
merchantId: string;
// Template identification
templateCode: string; // e.g., "1" (from tax authority)
templateSymbol: string; // e.g., "C25TAA"
modelNumber: string; // e.g., "01GTKT0/001"
// Naming
templateName: string; // Display name
invoiceType: InvoiceType; // 01GTKT, 02GTTT, etc.
// Numbering
currentNumber: number; // Last used number
startNumber: number; // Starting number (usually 1)
maxNumber: number; // Maximum before new template
resetAnnually: boolean; // Reset on new year
// Status
status: 'active' | 'inactive' | 'exhausted';
registeredAt: Date; // Registration with tax authority
expiresAt?: Date; // Template expiry
}InvoiceNumberingService
typescript
@injectable()
class InvoiceNumberingService {
constructor(
private templateRepository: InvoiceTemplateRepository,
private redis: Redis,
) {}
/**
* Get next invoice number for a template
* Thread-safe using Redis atomic operations
*/
async getNextNumber(templateId: string): Promise<string> {
const template = await this.templateRepository.findById(templateId);
if (!template) {
throw new TemplateNotFoundError(templateId);
}
if (template.status !== 'active') {
throw new TemplateInactiveError(templateId);
}
// Atomic increment using Redis
const key = `invoice:number:${templateId}`;
const nextNumber = await this.redis.incr(key);
// Check if template is exhausted
if (nextNumber > template.maxNumber) {
await this.templateRepository.updateStatus(templateId, 'exhausted');
throw new TemplateExhaustedError(templateId);
}
// Update database (async, for persistence)
await this.templateRepository.updateCurrentNumber(templateId, nextNumber);
// Format: C25TAA/00000001
return `${template.templateSymbol}/${String(nextNumber).padStart(8, '0')}`;
}
/**
* Reserve a range of numbers for batch processing
*/
async reserveRange(
templateId: string,
count: number,
): Promise<NumberRange> {
const template = await this.templateRepository.findById(templateId);
if (!template) {
throw new TemplateNotFoundError(templateId);
}
const key = `invoice:number:${templateId}`;
const endNumber = await this.redis.incrby(key, count);
const startNumber = endNumber - count + 1;
// Check if any number exceeds max
if (endNumber > template.maxNumber) {
// Rollback
await this.redis.decrby(key, count);
throw new InsufficientNumbersError(templateId, count);
}
await this.templateRepository.updateCurrentNumber(templateId, endNumber);
return {
templateId,
templateSymbol: template.templateSymbol,
startNumber,
endNumber,
numbers: Array.from(
{ length: count },
(_, i) => `${template.templateSymbol}/${String(startNumber + i).padStart(8, '0')}`,
),
};
}
/**
* Validate number sequence for audit
*/
async validateSequence(templateId: string): Promise<SequenceValidation> {
const template = await this.templateRepository.findById(templateId);
const invoices = await this.invoiceRepository.findByTemplate(templateId, {
orderBy: 'invoiceNumber',
});
const gaps: number[] = [];
const duplicates: number[] = [];
let prevNumber = 0;
for (const invoice of invoices) {
const number = this.extractNumber(invoice.invoiceNumber);
if (number === prevNumber) {
duplicates.push(number);
} else if (number !== prevNumber + 1) {
// Gap detected
for (let i = prevNumber + 1; i < number; i++) {
gaps.push(i);
}
}
prevNumber = number;
}
return {
templateId,
isValid: gaps.length === 0 && duplicates.length === 0,
totalInvoices: invoices.length,
gaps,
duplicates,
lastNumber: prevNumber,
};
}
/**
* Record cancelled number (for gap handling)
*/
async recordCancellation(invoiceNumber: string): Promise<void> {
const number = this.extractNumber(invoiceNumber);
const templateSymbol = this.extractTemplateSymbol(invoiceNumber);
const template = await this.templateRepository.findBySymbol(templateSymbol);
await this.cancelledNumberRepository.create({
templateId: template.id,
invoiceNumber,
number,
cancelledAt: new Date(),
});
}
private extractNumber(invoiceNumber: string): number {
const parts = invoiceNumber.split('/');
return parseInt(parts[1], 10);
}
private extractTemplateSymbol(invoiceNumber: string): string {
return invoiceNumber.split('/')[0];
}
}Number Generation Flow
Concurrency Handling
Redis Atomic Operations
typescript
// Single number - atomic increment
const nextNumber = await redis.incr(`invoice:number:${templateId}`);
// Range reservation - atomic increment by N
const endNumber = await redis.incrby(`invoice:number:${templateId}`, count);
// With Lua script for complex logic
const luaScript = `
local key = KEYS[1]
local max = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current > max then
redis.call('DECR', key)
return -1
end
return current
`;
const result = await redis.eval(luaScript, 1, key, template.maxNumber);Database Synchronization
typescript
// Sync Redis with database on startup
async function initializeNumberSequence(templateId: string): Promise<void> {
const template = await templateRepository.findById(templateId);
const lastInvoice = await invoiceRepository.findLastByTemplate(templateId);
const dbNumber = lastInvoice
? extractNumber(lastInvoice.invoiceNumber)
: template.startNumber - 1;
const redisKey = `invoice:number:${templateId}`;
const redisNumber = await redis.get(redisKey);
// Use the higher of the two
const currentNumber = Math.max(dbNumber, parseInt(redisNumber || '0', 10));
await redis.set(redisKey, currentNumber.toString());
await templateRepository.updateCurrentNumber(templateId, currentNumber);
}Annual Reset
Reset Configuration
typescript
interface AnnualResetConfig {
enabled: boolean;
resetDay: number; // Day of month (1-31)
resetMonth: number; // Month (1-12)
newTemplateSymbol: (year: number, currentSymbol: string) => string;
}
const resetConfig: AnnualResetConfig = {
enabled: true,
resetDay: 1,
resetMonth: 1, // January 1st
newTemplateSymbol: (year, current) => {
// C25TAA -> C26TAA
const prefix = current.substring(0, 1);
const suffix = current.substring(3);
return `${prefix}${year.toString().slice(-2)}${suffix}`;
},
};Annual Reset Process
typescript
async function performAnnualReset(templateId: string): Promise<InvoiceTemplate> {
const oldTemplate = await templateRepository.findById(templateId);
const newYear = new Date().getFullYear();
// Create new template for new year
const newTemplate = await templateRepository.create({
merchantId: oldTemplate.merchantId,
templateCode: oldTemplate.templateCode,
templateSymbol: resetConfig.newTemplateSymbol(newYear, oldTemplate.templateSymbol),
modelNumber: oldTemplate.modelNumber,
templateName: oldTemplate.templateName,
invoiceType: oldTemplate.invoiceType,
currentNumber: 0,
startNumber: 1,
maxNumber: oldTemplate.maxNumber,
resetAnnually: true,
status: 'active',
registeredAt: new Date(),
});
// Mark old template as inactive
await templateRepository.updateStatus(templateId, 'inactive');
// Initialize Redis counter for new template
await redis.set(`invoice:number:${newTemplate.id}`, '0');
return newTemplate;
}Gap Handling
Cancelled Number Record
typescript
interface CancelledNumber {
id: string;
templateId: string;
invoiceNumber: string;
number: number;
cancelledAt: Date;
reason: string;
cancelledBy: string;
}Gap Report
typescript
async function generateGapReport(templateId: string): Promise<GapReport> {
const validation = await numberingService.validateSequence(templateId);
const cancelledNumbers = await cancelledNumberRepository.findByTemplate(templateId);
const unexplainedGaps = validation.gaps.filter(
gap => !cancelledNumbers.some(cn => cn.number === gap),
);
return {
templateId,
totalGaps: validation.gaps.length,
cancelledNumbers: cancelledNumbers.map(cn => cn.number),
unexplainedGaps,
requiresAction: unexplainedGaps.length > 0,
};
}Audit Trail
Number Assignment Log
typescript
interface NumberAssignmentLog {
id: string;
templateId: string;
invoiceNumber: string;
invoiceId: string;
assignedAt: Date;
assignedBy: string;
}
// Log every number assignment
await numberAssignmentLogRepository.create({
id: generateId(),
templateId: template.id,
invoiceNumber: fullNumber,
invoiceId: invoice.id,
assignedAt: new Date(),
assignedBy: user.id,
});Error Handling
| Error | Code | Description |
|---|---|---|
TEMPLATE_NOT_FOUND | E001 | Template does not exist |
TEMPLATE_INACTIVE | E002 | Template is not active |
TEMPLATE_EXHAUSTED | E003 | All numbers used |
INSUFFICIENT_NUMBERS | E004 | Not enough numbers for range |
DUPLICATE_NUMBER | E005 | Number already assigned |
SEQUENCE_GAP | E006 | Gap detected in sequence |