Files
einvoice/ts/formats/validation/vat-categories.validator.ts

845 lines
25 KiB
TypeScript
Raw Normal View History

import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator } from '../utils/currency.utils.js';
import type { ValidationResult } from './validation.types.js';
/**
* VAT Category codes according to UNCL5305
*/
export enum VATCategory {
S = 'S', // Standard rate
Z = 'Z', // Zero rated
E = 'E', // Exempt from tax
AE = 'AE', // VAT Reverse Charge
K = 'K', // VAT exempt for EEA intra-community supply
G = 'G', // Free export outside EU
O = 'O', // Services outside scope of tax
L = 'L', // Canary Islands general indirect tax
M = 'M' // Tax for production, services and importation in Ceuta and Melilla
}
/**
* Extended VAT information for EN16931
*/
export interface VATBreakdown {
category: VATCategory;
rate: number;
taxableAmount: number;
taxAmount: number;
exemptionReason?: string;
exemptionReasonCode?: string;
}
/**
* Comprehensive VAT Category Rules Validator
* Implements all EN16931 VAT category-specific business rules
*/
export class VATCategoriesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
/**
* Validate VAT categories according to EN16931
*/
public validate(invoice: EInvoice): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
}
// Group items by VAT category
const itemsByCategory = this.groupItemsByVATCategory(invoice.items || []);
const breakdownsByCategory = this.groupBreakdownsByCategory(invoice.taxBreakdown || []);
// Validate each VAT category
this.validateStandardRate(itemsByCategory.get('S'), breakdownsByCategory.get('S'), invoice);
this.validateZeroRated(itemsByCategory.get('Z'), breakdownsByCategory.get('Z'), invoice);
this.validateExempt(itemsByCategory.get('E'), breakdownsByCategory.get('E'), invoice);
this.validateReverseCharge(itemsByCategory.get('AE'), breakdownsByCategory.get('AE'), invoice);
this.validateIntraCommunity(itemsByCategory.get('K'), breakdownsByCategory.get('K'), invoice);
this.validateExport(itemsByCategory.get('G'), breakdownsByCategory.get('G'), invoice);
this.validateOutOfScope(itemsByCategory.get('O'), breakdownsByCategory.get('O'), invoice);
// Cross-category validation
this.validateCrossCategoryRules(invoice, itemsByCategory, breakdownsByCategory);
return this.results;
}
/**
* Validate Standard Rate VAT (BR-S-*)
*/
private validateStandardRate(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-S-01: Invoice with standard rated items must have standard rated breakdown
if (!breakdown) {
this.addError('BR-S-01',
'Invoice with standard rated items must have a standard rated VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-S-02: Standard rate VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-S-02',
`Standard rate VAT taxable amount mismatch`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-S-03: Standard rate VAT category tax amount
const rate = breakdown.taxPercent || 0;
const expectedTax = this.calculateVATAmount(expectedTaxable, rate);
if (!this.areAmountsEqual(breakdown.taxAmount, expectedTax)) {
this.addError('BR-S-03',
`Standard rate VAT tax amount mismatch`,
'taxBreakdown.taxAmount',
breakdown.taxAmount,
expectedTax
);
}
// BR-S-04: Standard rate VAT category code must be "S"
if (breakdown.categoryCode && breakdown.categoryCode !== 'S') {
this.addError('BR-S-04',
'Standard rate VAT category code must be "S"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'S'
);
}
// BR-S-05: Standard rate VAT rate must be greater than zero
if (rate <= 0) {
this.addError('BR-S-05',
'Standard rate VAT rate must be greater than zero',
'taxBreakdown.taxPercent',
rate,
'> 0'
);
}
// BR-S-08: No exemption reason for standard rate
if (breakdown.exemptionReason) {
this.addError('BR-S-08',
'Standard rate VAT must not have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Validate Zero Rated VAT (BR-Z-*)
*/
private validateZeroRated(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-Z-01: Invoice with zero rated items must have zero rated breakdown
if (!breakdown) {
this.addError('BR-Z-01',
'Invoice with zero rated items must have a zero rated VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-Z-02: Zero rate VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-Z-02',
'Zero rate VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-Z-03: Zero rate VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-Z-03',
'Zero rate VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-Z-04: Zero rate VAT category code must be "Z"
if (breakdown.categoryCode && breakdown.categoryCode !== 'Z') {
this.addError('BR-Z-04',
'Zero rate VAT category code must be "Z"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'Z'
);
}
// BR-Z-05: Zero rate VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-Z-05',
'Zero rate VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
}
/**
* Validate Exempt from Tax (BR-E-*)
*/
private validateExempt(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-E-01: Invoice with exempt items must have exempt breakdown
if (!breakdown) {
this.addError('BR-E-01',
'Invoice with tax exempt items must have an exempt VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-E-02: Exempt VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-E-02',
'Exempt VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-E-03: Exempt VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-E-03',
'Exempt VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-E-04: Exempt VAT category code must be "E"
if (breakdown.categoryCode && breakdown.categoryCode !== 'E') {
this.addError('BR-E-04',
'Exempt VAT category code must be "E"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'E'
);
}
// BR-E-05: Exempt VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-E-05',
'Exempt VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-E-06: Exempt VAT must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-E-06',
'Exempt VAT must have an exemption reason or exemption reason code',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Validate VAT Reverse Charge (BR-AE-*)
*/
private validateReverseCharge(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-AE-01: Invoice with reverse charge items must have reverse charge breakdown
if (!breakdown) {
this.addError('BR-AE-01',
'Invoice with reverse charge items must have a reverse charge VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-AE-02: Reverse charge VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-AE-02',
'Reverse charge VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-AE-03: Reverse charge VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-AE-03',
'Reverse charge VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-AE-04: Reverse charge VAT category code must be "AE"
if (breakdown.categoryCode && breakdown.categoryCode !== 'AE') {
this.addError('BR-AE-04',
'Reverse charge VAT category code must be "AE"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'AE'
);
}
// BR-AE-05: Reverse charge VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-AE-05',
'Reverse charge VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-AE-06: Reverse charge must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-AE-06',
'Reverse charge VAT must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-AE-08: Buyer must have VAT identifier for reverse charge
if (!invoice?.metadata?.buyerTaxId) {
this.addError('BR-AE-08',
'Buyer must have a VAT identifier for reverse charge invoices',
'metadata.buyerTaxId'
);
}
}
/**
* Validate Intra-Community Supply (BR-K-*)
*/
private validateIntraCommunity(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-K-01: Invoice with intra-community items must have intra-community breakdown
if (!breakdown) {
this.addError('BR-K-01',
'Invoice with intra-community supply must have corresponding VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-K-02: Intra-community VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-K-02',
'Intra-community VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-K-03: Intra-community VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-K-03',
'Intra-community VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-K-04: Intra-community VAT category code must be "K"
if (breakdown.categoryCode && breakdown.categoryCode !== 'K') {
this.addError('BR-K-04',
'Intra-community VAT category code must be "K"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'K'
);
}
// BR-K-05: Intra-community VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-K-05',
'Intra-community VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-K-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-K-06',
'Intra-community supply must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-K-08: Both seller and buyer must have VAT identifiers
if (!invoice?.metadata?.sellerTaxId) {
this.addError('BR-K-08',
'Seller must have a VAT identifier for intra-community supply',
'metadata.sellerTaxId'
);
}
if (!invoice?.metadata?.buyerTaxId) {
this.addError('BR-K-09',
'Buyer must have a VAT identifier for intra-community supply',
'metadata.buyerTaxId'
);
}
// BR-K-10: Must be in different EU member states
if (invoice?.from?.address?.countryCode === invoice?.to?.address?.countryCode) {
this.addWarning('BR-K-10',
'Intra-community supply should be between different EU member states',
'address.countryCode'
);
}
}
/**
* Validate Export Outside EU (BR-G-*)
*/
private validateExport(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-G-01: Invoice with export items must have export breakdown
if (!breakdown) {
this.addError('BR-G-01',
'Invoice with export items must have an export VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-G-02: Export VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-G-02',
'Export VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-G-03: Export VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-G-03',
'Export VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-G-04: Export VAT category code must be "G"
if (breakdown.categoryCode && breakdown.categoryCode !== 'G') {
this.addError('BR-G-04',
'Export VAT category code must be "G"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'G'
);
}
// BR-G-05: Export VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-G-05',
'Export VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-G-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-G-06',
'Export must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-G-08: Buyer should be outside EU
const buyerCountry = invoice?.to?.address?.countryCode;
if (buyerCountry && this.isEUCountry(buyerCountry)) {
this.addWarning('BR-G-08',
'Export category should be used for buyers outside EU',
'to.address.countryCode',
buyerCountry,
'non-EU'
);
}
}
/**
* Validate Out of Scope Services (BR-O-*)
*/
private validateOutOfScope(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-O-01: Invoice with out of scope items must have out of scope breakdown
if (!breakdown) {
this.addError('BR-O-01',
'Invoice with out of scope items must have corresponding VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-O-02: Out of scope VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-O-02',
'Out of scope VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-O-03: Out of scope VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-O-03',
'Out of scope VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-O-04: Out of scope VAT category code must be "O"
if (breakdown.categoryCode && breakdown.categoryCode !== 'O') {
this.addError('BR-O-04',
'Out of scope VAT category code must be "O"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'O'
);
}
// BR-O-05: Out of scope VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-O-05',
'Out of scope VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-O-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-O-06',
'Out of scope services must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Cross-category validation rules
*/
private validateCrossCategoryRules(
invoice: EInvoice,
itemsByCategory: Map<string, TAccountingDocItem[]>,
breakdownsByCategory: Map<string, any>
): void {
// BR-CO-17: VAT category tax amount = Σ(VAT category taxable amount × VAT rate)
breakdownsByCategory.forEach((breakdown, category) => {
if (category === 'S' && breakdown.taxPercent > 0) {
const expectedTax = this.calculateVATAmount(breakdown.netAmount, breakdown.taxPercent);
if (!this.areAmountsEqual(breakdown.taxAmount, expectedTax)) {
this.addError('BR-CO-17',
`VAT tax amount calculation error for category ${category}`,
'taxBreakdown.taxAmount',
breakdown.taxAmount,
expectedTax
);
}
}
});
// BR-CO-18: Invoice with mixed VAT categories
const categoriesUsed = new Set<string>();
itemsByCategory.forEach((items, category) => {
if (items.length > 0) categoriesUsed.add(category);
});
// BR-IC-01: Supply to EU countries without VAT ID should use standard rate
if (categoriesUsed.has('K') && !invoice.metadata?.buyerTaxId) {
this.addError('BR-IC-01',
'Intra-community supply requires buyer VAT identifier',
'metadata.buyerTaxId'
);
}
// BR-IC-02: Reverse charge requires specific conditions
if (categoriesUsed.has('AE')) {
// Check for service codes that qualify for reverse charge
const hasQualifyingServices = invoice.items?.some(item =>
this.isReverseChargeService(item)
);
if (!hasQualifyingServices) {
this.addWarning('BR-IC-02',
'Reverse charge should only be used for qualifying services',
'items'
);
}
}
// BR-CO-19: Sum of VAT breakdown taxable amounts must equal invoice tax exclusive total
let totalTaxable = 0;
breakdownsByCategory.forEach(breakdown => {
totalTaxable += breakdown.netAmount || 0;
});
const declaredTotal = invoice.totalNet || 0;
if (!this.areAmountsEqual(totalTaxable, declaredTotal)) {
this.addError('BR-CO-19',
'Sum of VAT breakdown taxable amounts must equal invoice total without VAT',
'totalNet',
declaredTotal,
totalTaxable
);
}
}
// Helper methods
private groupItemsByVATCategory(items: TAccountingDocItem[]): Map<string, TAccountingDocItem[]> {
const groups = new Map<string, TAccountingDocItem[]>();
items.forEach(item => {
const category = this.determineVATCategory(item);
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(item);
});
return groups;
}
private groupBreakdownsByCategory(breakdowns: any[]): Map<string, any> {
const groups = new Map<string, any>();
breakdowns.forEach(breakdown => {
const category = breakdown.categoryCode || this.inferCategoryFromRate(breakdown.taxPercent);
groups.set(category, breakdown);
});
return groups;
}
private determineVATCategory(item: TAccountingDocItem): string {
// Determine VAT category from item metadata or rate
const metadata = (item as any).metadata;
if (metadata?.vatCategory) {
return metadata.vatCategory;
}
// Infer from rate
if (item.vatPercentage === undefined || item.vatPercentage === null) {
return 'S'; // Default to standard
} else if (item.vatPercentage > 0) {
return 'S'; // Standard rate
} else if (item.vatPercentage === 0) {
// Could be Z, E, AE, K, G, or O - need more context
if (metadata?.exemptionReason) {
if (metadata.exemptionReason.includes('reverse')) return 'AE';
if (metadata.exemptionReason.includes('intra')) return 'K';
if (metadata.exemptionReason.includes('export')) return 'G';
if (metadata.exemptionReason.includes('scope')) return 'O';
return 'E'; // Default exempt
}
return 'Z'; // Default zero-rated
}
return 'S'; // Default
}
private inferCategoryFromRate(rate?: number): string {
if (!rate || rate === 0) return 'Z';
if (rate > 0) return 'S';
return 'S';
}
private calculateTaxableAmount(items: TAccountingDocItem[]): number {
const total = items.reduce((sum, item) => {
const lineNet = (item.unitNetPrice || 0) * (item.unitQuantity || 0);
return sum + (this.currencyCalculator ? this.currencyCalculator.round(lineNet) : lineNet);
}, 0);
return this.currencyCalculator ? this.currencyCalculator.round(total) : total;
}
private calculateVATAmount(taxableAmount: number, rate: number): number {
const vat = taxableAmount * (rate / 100);
return this.currencyCalculator ? this.currencyCalculator.round(vat) : vat;
}
private areAmountsEqual(value1: number, value2: number): boolean {
if (this.currencyCalculator) {
return this.currencyCalculator.areEqual(value1, value2);
}
return Math.abs(value1 - value2) < 0.01;
}
private isEUCountry(countryCode: string): boolean {
const euCountries = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'
];
return euCountries.includes(countryCode);
}
private isReverseChargeService(item: TAccountingDocItem): boolean {
// Check if item qualifies for reverse charge
// This would typically check service codes
const metadata = (item as any).metadata;
if (metadata?.serviceCode) {
// Construction services, telecommunication, etc.
const reverseChargeServices = ['44', '45', '61', '62'];
return reverseChargeServices.some(code =>
metadata.serviceCode.startsWith(code)
);
}
return false;
}
private addError(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'error',
message,
field,
value,
expected,
btReference: this.getBTReference(ruleId),
bgReference: 'BG-23' // VAT breakdown
});
}
private addWarning(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'warning',
message,
field,
value,
expected,
btReference: this.getBTReference(ruleId),
bgReference: 'BG-23'
});
}
private getBTReference(ruleId: string): string | undefined {
const btMap: Record<string, string> = {
'BR-S-': 'BT-118', // VAT category rate
'BR-Z-': 'BT-118',
'BR-E-': 'BT-120', // VAT exemption reason
'BR-AE-': 'BT-120',
'BR-K-': 'BT-120',
'BR-G-': 'BT-120',
'BR-O-': 'BT-120',
'BR-CO-17': 'BT-117', // VAT category tax amount
'BR-CO-18': 'BT-118',
'BR-CO-19': 'BT-116' // VAT category taxable amount
};
for (const [prefix, bt] of Object.entries(btMap)) {
if (ruleId.startsWith(prefix)) {
return bt;
}
}
return undefined;
}
}
/**
* Get VAT category name
*/
export function getVATCategoryName(category: VATCategory): string {
const names: Record<VATCategory, string> = {
[VATCategory.S]: 'Standard rate',
[VATCategory.Z]: 'Zero rated',
[VATCategory.E]: 'Exempt from tax',
[VATCategory.AE]: 'VAT Reverse Charge',
[VATCategory.K]: 'VAT exempt for EEA intra-community supply',
[VATCategory.G]: 'Free export outside EU',
[VATCategory.O]: 'Services outside scope of tax',
[VATCategory.L]: 'Canary Islands general indirect tax',
[VATCategory.M]: 'Tax for production, services and importation in Ceuta and Melilla'
};
return names[category] || 'Unknown';
}