UNPKG

invoice-craft

Version:

Customizable, browser-first invoice PDF generator library with modern TypeScript API

264 lines (263 loc) 11.7 kB
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(); }