Skip to content

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

RuleDescription
SequentialNumbers must be consecutive (1, 2, 3...)
No GapsGaps require cancellation records
Per TemplateSeparate sequence per invoice template
Zero-PaddedNumbers are 8 digits with leading zeros
ResetCan 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 template

Template 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

ErrorCodeDescription
TEMPLATE_NOT_FOUNDE001Template does not exist
TEMPLATE_INACTIVEE002Template is not active
TEMPLATE_EXHAUSTEDE003All numbers used
INSUFFICIENT_NUMBERSE004Not enough numbers for range
DUPLICATE_NUMBERE005Number already assigned
SEQUENCE_GAPE006Gap detected in sequence

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