- Added validation types for EN16931 compliance in `validation.types.ts`, including interfaces for `ValidationResult`, `ValidationOptions`, and `ValidationReport`. - Introduced `VATCategoriesValidator` in `vat-categories.validator.ts` to validate VAT categories according to EN16931 rules, including detailed checks for standard, zero-rated, exempt, reverse charge, intra-community, export, and out-of-scope services. - Enhanced `IEInvoiceMetadata` interface in `en16931-metadata.ts` to include additional fields required for full standards compliance, such as delivery information, payment information, allowances, and charges. - Implemented helper methods for VAT calculations and validation logic to ensure accurate compliance with EN16931 standards.
845 lines
25 KiB
TypeScript
845 lines
25 KiB
TypeScript
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';
|
||
} |