Issue #221: Remove ambiguous chars from Document Numbers¶
Status: ✅ Closed Priority: MUST have Labels: F: Invoicing Created: 2025-10-29 Resolved: 2025-10-29
Problem¶
To avoid problems when people manually copy the document number or spell it on the phone, ambiguous characters like 1, I, l, O, 0 needed to be removed from document_ids.
These characters are commonly confused when reading or writing manually:
- 0 (zero) vs O (uppercase o)
- 1 (one) vs I (uppercase i) vs l (lowercase L)
Solution¶
Implementation¶
Modified document number generation to exclude ambiguous characters by implementing a custom base32 alphabet.
Custom Alphabet:
const UNAMBIGUOUS_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
This alphabet contains 32 characters (perfect for base32 encoding) and excludes:
- 0 (zero) - looks like O
- 1 (one) - looks like I or l
- I (uppercase i) - looks like 1 or l
- O (uppercase o) - looks like 0
Base Conversion Function:
function toUnambiguousBase(num: number): string {
if (num === 0) return UNAMBIGUOUS_ALPHABET[0];
const base = UNAMBIGUOUS_ALPHABET.length;
let result = "";
let remaining = num;
while (remaining > 0) {
const remainder = remaining % base;
result = UNAMBIGUOUS_ALPHABET[remainder] + result;
remaining = Math.floor(remaining / base);
}
return result;
}
Updated Document Number Generator:
export function generateDocumentNumber(
documentType: DocumentType,
prefix?: string | null
): string {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
const hour = today.getHours();
const minute = today.getMinutes();
// Create a number from timestamp: YYMMDDHHmm
const megaNumber =
year.toString().slice(-2) +
month.toString().padStart(2, "0") +
day.toString().padStart(2, "0") +
hour.toString().padStart(2, "0") +
minute.toString().padStart(2, "0");
// Convert to unambiguous base32 for shorter, clearer representation
const letters = toUnambiguousBase(parseInt(megaNumber, 10));
// Use custom prefix or default to first 2 letters of document type
const documentPrefix = prefix || documentType.toUpperCase().slice(0, 2);
return `${documentPrefix}-${letters}`;
}
Benefits¶
- Easier to read - No confusion between similar-looking characters
- Better for phone communication - Spelling document numbers over the phone is now unambiguous
- Reduced manual entry errors - Less likely to make mistakes when copying document numbers
- Maintains short format - Base32 still provides compact representation
Examples¶
Before (base36):
- INV-1A2B3C4 (could contain 0, 1, I, O)
- INV-O0I1L23 (very confusing!)
After (unambiguous base32):
- INV-2A3B4C5 (only contains 2-9, A-H, J-N, P-Z)
- INV-2K3M4P5 (clear and unambiguous)
Files Modified¶
apps/api/src/utils/documentNumber.ts- Updated generation logic with custom alphabetapps/api/src/utils/documentNumber.test.ts- Added 22 comprehensive unit tests (new file)
Testing¶
Test Coverage¶
Created comprehensive test suite with 22 tests covering:
- Basic generation (5 tests)
- Default prefix generation
- Custom prefix generation
- Different document types (invoice, offer, credit_note)
-
Null prefix handling
-
Ambiguous character exclusion (5 tests)
- Verify no
0(zero) in generated numbers (100 samples) - Verify no
1(one) in generated numbers (100 samples) - Verify no
I(uppercase i) in generated numbers (100 samples) - Verify no
O(uppercase o) in generated numbers (100 samples) -
Verify only allowed characters (2-9, A-H, J-N, P-Z)
-
Consistency and uniqueness (3 tests)
- Same timestamp produces same number
- Different timestamps produce different numbers
-
Consistent format validation
-
Edge cases (4 tests)
- Year boundary (2099)
- Start of year (2000)
- Midnight timestamp
-
Reasonable length validation
-
Real-world scenarios (3 tests)
- Easy to transcribe verification
- Multiple document types
-
Empty string prefix handling
-
Specific timestamp tests (2 tests)
- Known timestamp validation
- Consistent results for same timestamp
Test Results¶
✅ All 22 tests passing
✅ 100% code coverage for documentNumber.ts
✅ All ambiguous characters verified excluded
Sample Test Output¶
PASS src/utils/documentNumber.test.ts
generateDocumentNumber
Basic generation
✓ should generate document number with default prefix (2 ms)
✓ should generate document number with custom prefix (1 ms)
✓ should generate document number for offer type
✓ should generate document number for credit_note type
✓ should handle null prefix
Ambiguous character exclusion
✓ should NOT contain zero (0) in generated number (3 ms)
✓ should NOT contain one (1) in generated number (4 ms)
✓ should NOT contain uppercase I in generated number (2 ms)
✓ should NOT contain uppercase O in generated number (2 ms)
✓ should only contain allowed characters (2-9, A-H, J-N, P-Z) (2 ms)
Consistency and uniqueness
✓ should generate same number for same timestamp (1 ms)
✓ should generate different numbers for different timestamps (1 ms)
✓ should generate numbers with consistent format
Edge cases
✓ should handle year boundary (2099)
✓ should handle start of year (2000) (1 ms)
✓ should handle midnight timestamp
✓ should generate reasonable length numbers
Real-world usage scenarios
✓ should be easy to read and transcribe (no ambiguous chars)
✓ should work with various document types (1 ms)
✓ should work with empty string prefix (use default)
Specific timestamp tests
✓ should handle timestamp 2510291430 correctly
✓ should produce consistent results for known timestamp
Test Suites: 1 passed, 1 total
Tests: 22 passed, 22 total
Technical Details¶
Base Conversion Algorithm¶
The toUnambiguousBase() function converts decimal numbers to base32 using the custom alphabet:
- Handle zero case (return first character)
- Calculate remainder when dividing by base (32)
- Use remainder as index into alphabet
- Prepend character to result
- Integer divide number by base
- Repeat until number is 0
Character Distribution¶
The unambiguous alphabet ensures even distribution:
- Digits: 2-9 (8 characters)
- Letters: A-H, J-N, P-Z (24 characters)
- Total: 32 characters (perfect for base32)
Performance¶
- Conversion is O(log₃₂(n)) where n is the timestamp number
- Typical conversion: ~6-8 characters for modern timestamps
- Negligible performance impact compared to base36
Migration Notes¶
Existing Documents¶
Existing documents with old document numbers (containing 0, 1, I, O) will continue to work. The change only affects newly generated document numbers.
Backward Compatibility¶
✅ No database migration required ✅ Old document numbers remain valid ✅ No breaking changes to API ✅ No user action required
Related Issues¶
- None
References¶
- Commit:
7a4b432 - Files:
apps/api/src/utils/documentNumber.ts,apps/api/src/utils/documentNumber.test.ts