invoice-craft
Version:
Customizable, browser-first invoice PDF generator library with modern TypeScript API
264 lines (263 loc) • 11.7 kB
JavaScript
export class InvoiceValidator {
constructor() {
this.errors = [];
this.warnings = [];
}
validate(invoice) {
this.errors = [];
this.warnings = [];
this.validateBasicStructure(invoice);
this.validateParties(invoice);
this.validateItems(invoice);
this.validateDates(invoice);
this.validateCurrency(invoice);
this.validateNumbers(invoice);
this.validateBusinessRules(invoice);
return {
isValid: this.errors.length === 0,
errors: this.errors,
warnings: this.warnings
};
}
validateBasicStructure(invoice) {
if (!invoice) {
this.addError('root', 'Invoice data is required', 'MISSING_INVOICE');
return;
}
if (!invoice.invoiceNumber || typeof invoice.invoiceNumber !== 'string') {
this.addError('invoiceNumber', 'Invoice number is required and must be a string', 'INVALID_INVOICE_NUMBER');
}
else if (invoice.invoiceNumber.trim().length === 0) {
this.addError('invoiceNumber', 'Invoice number cannot be empty', 'EMPTY_INVOICE_NUMBER');
}
if (!invoice.invoiceDate || typeof invoice.invoiceDate !== 'string') {
this.addError('invoiceDate', 'Invoice date is required and must be a string', 'INVALID_INVOICE_DATE');
}
if (!invoice.items || !Array.isArray(invoice.items)) {
this.addError('items', 'Items array is required', 'MISSING_ITEMS');
}
else if (invoice.items.length === 0) {
this.addError('items', 'Invoice must have at least one item', 'EMPTY_ITEMS');
}
}
validateParties(invoice) {
// Validate 'from' party
if (!invoice.from) {
this.addError('from', 'Sender information is required', 'MISSING_FROM');
}
else {
this.validateParty(invoice.from, 'from');
}
// Validate 'to' party
if (!invoice.to) {
this.addError('to', 'Recipient information is required', 'MISSING_TO');
}
else {
this.validateParty(invoice.to, 'to');
}
}
validateParty(party, prefix) {
if (!party.name || typeof party.name !== 'string') {
this.addError(`${prefix}.name`, 'Party name is required and must be a string', 'INVALID_PARTY_NAME');
}
else if (party.name.trim().length === 0) {
this.addError(`${prefix}.name`, 'Party name cannot be empty', 'EMPTY_PARTY_NAME');
}
if (party.email && !this.isValidEmail(party.email)) {
this.addError(`${prefix}.email`, 'Invalid email format', 'INVALID_EMAIL');
}
if (party.phone && !this.isValidPhone(party.phone)) {
this.addWarning(`${prefix}.phone`, 'Phone number format may be invalid', 'INVALID_PHONE');
}
if (party.logoUrl && !this.isValidUrl(party.logoUrl) && !this.isValidDataUri(party.logoUrl)) {
this.addWarning(`${prefix}.logoUrl`, 'Logo URL format may be invalid', 'INVALID_LOGO_URL');
}
if (party.brandColor && !this.isValidColor(party.brandColor)) {
this.addWarning(`${prefix}.brandColor`, 'Brand color format may be invalid (use hex format like #FF0000)', 'INVALID_BRAND_COLOR');
}
}
validateItems(invoice) {
if (!invoice.items || !Array.isArray(invoice.items)) {
return; // Already handled in basic structure validation
}
invoice.items.forEach((item, index) => {
const prefix = `items[${index}]`;
if (!item.description || typeof item.description !== 'string') {
this.addError(`${prefix}.description`, 'Item description is required and must be a string', 'INVALID_ITEM_DESCRIPTION');
}
else if (item.description.trim().length === 0) {
this.addError(`${prefix}.description`, 'Item description cannot be empty', 'EMPTY_ITEM_DESCRIPTION');
}
if (typeof item.quantity !== 'number') {
this.addError(`${prefix}.quantity`, 'Item quantity must be a number', 'INVALID_ITEM_QUANTITY');
}
else if (item.quantity <= 0) {
this.addError(`${prefix}.quantity`, 'Item quantity must be greater than 0', 'INVALID_ITEM_QUANTITY_VALUE');
}
else if (!Number.isInteger(item.quantity)) {
this.addWarning(`${prefix}.quantity`, 'Item quantity should typically be a whole number', 'NON_INTEGER_QUANTITY');
}
if (typeof item.unitPrice !== 'number') {
this.addError(`${prefix}.unitPrice`, 'Item unit price must be a number', 'INVALID_ITEM_PRICE');
}
else if (item.unitPrice < 0) {
this.addError(`${prefix}.unitPrice`, 'Item unit price cannot be negative', 'NEGATIVE_ITEM_PRICE');
}
if (item.taxRate !== undefined) {
if (typeof item.taxRate !== 'number') {
this.addError(`${prefix}.taxRate`, 'Tax rate must be a number', 'INVALID_TAX_RATE');
}
else if (item.taxRate < 0 || item.taxRate > 1) {
this.addError(`${prefix}.taxRate`, 'Tax rate must be between 0 and 1 (e.g., 0.1 for 10%)', 'INVALID_TAX_RATE_VALUE');
}
}
});
}
validateDates(invoice) {
if (invoice.invoiceDate && !this.isValidDate(invoice.invoiceDate)) {
this.addError('invoiceDate', 'Invoice date must be in YYYY-MM-DD format', 'INVALID_DATE_FORMAT');
}
if (invoice.dueDate) {
if (!this.isValidDate(invoice.dueDate)) {
this.addError('dueDate', 'Due date must be in YYYY-MM-DD format', 'INVALID_DATE_FORMAT');
}
else if (invoice.invoiceDate && this.isValidDate(invoice.invoiceDate)) {
const invoiceDate = new Date(invoice.invoiceDate);
const dueDate = new Date(invoice.dueDate);
if (dueDate < invoiceDate) {
this.addWarning('dueDate', 'Due date is before invoice date', 'DUE_DATE_BEFORE_INVOICE');
}
}
}
}
validateCurrency(invoice) {
if (invoice.currency) {
if (typeof invoice.currency !== 'string') {
this.addError('currency', 'Currency must be a string', 'INVALID_CURRENCY_TYPE');
}
else if (!this.isValidCurrencyCode(invoice.currency)) {
this.addWarning('currency', 'Currency code may be invalid (use ISO 4217 codes like USD, EUR)', 'INVALID_CURRENCY_CODE');
}
}
}
validateNumbers(invoice) {
if (invoice.discount !== undefined) {
if (typeof invoice.discount !== 'number') {
this.addError('discount', 'Discount must be a number', 'INVALID_DISCOUNT');
}
else if (invoice.discount < 0) {
this.addError('discount', 'Discount cannot be negative', 'NEGATIVE_DISCOUNT');
}
}
}
validateBusinessRules(invoice) {
// Check for duplicate invoice numbers (this would typically check against a database)
if (invoice.invoiceNumber && invoice.invoiceNumber.length < 3) {
this.addWarning('invoiceNumber', 'Invoice number is very short, consider using a longer format', 'SHORT_INVOICE_NUMBER');
}
// Check total value
if (invoice.items && Array.isArray(invoice.items)) {
const total = invoice.items.reduce((sum, item) => {
if (typeof item.quantity === 'number' && typeof item.unitPrice === 'number') {
return sum + (item.quantity * item.unitPrice);
}
return sum;
}, 0);
if (total === 0) {
this.addWarning('items', 'Invoice total is zero', 'ZERO_TOTAL');
}
else if (total > 1000000) {
this.addWarning('items', 'Invoice total is very high, please verify', 'HIGH_TOTAL');
}
}
// Check for missing contact information
if (invoice.from && !invoice.from.email && !invoice.from.phone) {
this.addWarning('from', 'Consider adding contact information (email or phone)', 'MISSING_CONTACT_INFO');
}
if (invoice.to && !invoice.to.email && !invoice.to.phone) {
this.addWarning('to', 'Consider adding recipient contact information', 'MISSING_RECIPIENT_CONTACT');
}
}
addError(field, message, code) {
this.errors.push({ field, message, code, severity: 'error' });
}
addWarning(field, message, code) {
this.warnings.push({ field, message, code, severity: 'warning' });
}
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
isValidPhone(phone) {
// Basic phone validation - accepts various formats
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
const cleanPhone = phone.replace(/[\s\-\(\)\.]/g, '');
return phoneRegex.test(cleanPhone);
}
isValidUrl(url) {
try {
new URL(url);
return true;
}
catch {
return false;
}
}
isValidDataUri(uri) {
return uri.startsWith('data:');
}
isValidColor(color) {
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
return hexRegex.test(color);
}
isValidDate(dateString) {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateString)) {
return false;
}
const date = new Date(dateString);
return date instanceof Date && !isNaN(date.getTime()) &&
date.toISOString().slice(0, 10) === dateString;
}
isValidCurrencyCode(code) {
// Common currency codes - in a real app, you'd have a complete list
const commonCurrencies = [
'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'CNY', 'SEK', 'NZD',
'MXN', 'SGD', 'HKD', 'NOK', 'TRY', 'RUB', 'INR', 'BRL', 'ZAR', 'KRW'
];
return commonCurrencies.includes(code.toUpperCase());
}
}
export function validateInvoice(invoice) {
const validator = new InvoiceValidator();
return validator.validate(invoice);
}
export function validateInvoiceStrict(invoice) {
const result = validateInvoice(invoice);
// In strict mode, treat some warnings as errors
const strictErrors = result.warnings.filter(warning => ['INVALID_EMAIL', 'INVALID_CURRENCY_CODE', 'DUE_DATE_BEFORE_INVOICE'].includes(warning.code));
return {
isValid: result.isValid && strictErrors.length === 0,
errors: [...result.errors, ...strictErrors.map(w => ({ ...w, severity: 'error' }))],
warnings: result.warnings.filter(warning => !['INVALID_EMAIL', 'INVALID_CURRENCY_CODE', 'DUE_DATE_BEFORE_INVOICE'].includes(warning.code))
};
}
export function getValidationSummary(result) {
if (result.isValid && result.warnings.length === 0) {
return 'Invoice validation passed with no issues.';
}
let summary = '';
if (result.errors.length > 0) {
summary += `${result.errors.length} error(s) found:\n`;
result.errors.forEach(error => {
summary += `- ${error.field}: ${error.message}\n`;
});
}
if (result.warnings.length > 0) {
summary += `${result.warnings.length} warning(s):\n`;
result.warnings.forEach(warning => {
summary += `- ${warning.field}: ${warning.message}\n`;
});
}
return summary.trim();
}