import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { EInvoiceValidatorService } from './einvoice-validator.service';
import { AccountingArtifactStorageService } from './accounting-artifact-storage.service';
import { assertEInvoiceBuildInput, assertEInvoiceValidateInput } from './invoice-input-validation';
@Injectable()
export class EInvoiceService {
constructor(
private readonly database:DatabaseService,
private readonly validator:EInvoiceValidatorService,
private readonly artifacts:AccountingArtifactStorageService,
) {}
async buildXml(input:{organizationId:string;invoiceId:string;profile:'xrechnung'|'zugferd'}) {
assertEInvoiceBuildInput(input);
const inv=await this.database.query<any>(
`select i.*,c.name as customer_name,c.email as customer_email,
c.vat_id as customer_vat_id,c.buyer_reference as customer_buyer_reference,
c.electronic_address,c.address_json,c.locale
from invoices i join invoice_customers c on c.id=i.invoice_customer_id
where i.id=$1 and i.organization_id=$2 and i.status='finalized'`,
[input.invoiceId,input.organizationId],
);
if(!inv[0]) throw new DomainConflictError('einvoice.invoice_not_finalized');
const lines=await this.database.query<any>(
`select * from invoice_lines where invoice_id=$1 order by line_number`,
[input.invoiceId],
);
const taxes=await this.database.query<any>(
`select * from invoice_tax_summaries where invoice_id=$1 order by tax_rate_basis_points`,
[input.invoiceId],
);
if(input.profile==='xrechnung' && !inv[0].buyer_reference && !inv[0].customer_buyer_reference) {
throw new DomainConflictError('einvoice.buyer_reference_required');
}
const esc=(s:any)=>String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const money=(minor:any)=>{
const n=BigInt(minor??0),sign=n<0n?'-':'',abs=n<0n?-n:n;
return `${sign}${abs/100n}.${String(abs%100n).padStart(2,'0')}`;
};
const xml=[
'<?xml version="1.0" encoding="UTF-8"?>',
`<Invoice profile="${esc(input.profile)}">`,
`<ID>${esc(inv[0].invoice_number)}</ID>`,
`<DocumentType>${esc(inv[0].document_type)}</DocumentType>`,
`<IssueDate>${esc(inv[0].issue_date)}</IssueDate>`,
`<Currency>${esc(inv[0].currency_code)}</Currency>`,
`<BuyerReference>${esc(inv[0].buyer_reference??inv[0].customer_buyer_reference??'')}</BuyerReference>`,
`<Buyer><Name>${esc(inv[0].customer_name)}</Name><Email>${esc(inv[0].customer_email??'')}</Email><VATID>${esc(inv[0].customer_vat_id??'')}</VATID></Buyer>`,
'<Lines>',
...lines.map((l:any)=>`<Line><ID>${l.line_number}</ID><Name>${esc(l.description)}</Name><Quantity>${esc(l.quantity)}</Quantity><Net>${money(l.net_amount_minor)}</Net><TaxRate>${Number(l.tax_rate_basis_points)/100}</TaxRate></Line>`),
'</Lines>',
'<Taxes>',
...taxes.map((x:any)=>`<Tax><Rate>${Number(x.tax_rate_basis_points)/100}</Rate><Net>${money(x.taxable_net_minor)}</Net><Tax>${money(x.tax_minor)}</Tax></Tax>`),
'</Taxes>',
`<Totals><Net>${money(inv[0].net_total_minor)}</Net><Tax>${money(inv[0].tax_total_minor)}</Tax><Gross>${money(inv[0].gross_total_minor)}</Gross></Totals>`,
'</Invoice>',
].join('');
return {profile:input.profile,xml,invoice:inv[0]};
}
async validate(input:{
organizationId:string;
invoiceId:string;
profile:'xrechnung'|'zugferd';
validatorConnectionId?:string;
userId?:string;
}) {
assertEInvoiceValidateInput(input);
const generated=await this.buildXml(input);
const baselineMessages:any[]=[];
const inv=generated.invoice;
if(!inv.invoice_number) baselineMessages.push({code:'invoice_number_missing',severity:'error'});
if(BigInt(inv.gross_total_minor)!==BigInt(inv.net_total_minor)+BigInt(inv.tax_total_minor)) {
baselineMessages.push({code:'totals_mismatch',severity:'error'});
}
if(input.profile==='xrechnung' && !inv.buyer_reference && !inv.customer_buyer_reference) {
baselineMessages.push({code:'buyer_reference_missing',severity:'error'});
}
const external=baselineMessages.length
? {valid:false,validator:'baseline',messages:baselineMessages}
: await this.validator.validate({
organizationId:input.organizationId,
xml:generated.xml,
profile:input.profile,
connectionId:input.validatorConnectionId,
});
const valid=baselineMessages.length===0 && external.valid;
const messages=[...baselineMessages,...external.messages];
const validationId=randomUUID();
await this.database.transaction(async(tx)=>{
await tx.query(
`insert into e_invoice_validation_results(
id,organization_id,invoice_id,profile,validator,valid,messages_json
) values($1,$2,$3,$4,$5,$6,$7::jsonb)`,
[
validationId,input.organizationId,input.invoiceId,input.profile,
external.validator,valid,JSON.stringify(messages),
],
);
await tx.query(
`update invoices
set e_invoice_profile=$1,e_invoice_status=$2,e_invoice_validation_json=$3::jsonb,
status=case when status='finalized' then status else status end,version=version+1
where id=$4`,
[input.profile,valid?'valid':'invalid',JSON.stringify({validator:external.validator,messages}),input.invoiceId],
);
});
const artifact=await this.artifacts.store({
organizationId:input.organizationId,
locationId:inv.location_id,
sourceType:'invoice',
sourceId:input.invoiceId,
artifactType:'invoice_xml',
profile:input.profile,
fileName:`${inv.invoice_number}-${input.profile}.xml`,
mimeType:'application/xml',
content:Buffer.from(generated.xml,'utf8'),
userId:input.userId,
});
await this.database.query(
`update invoices set xml_file_asset_id=$1 where id=$2`,
[artifact.fileAssetId,input.invoiceId],
);
return {
valid,
validator:external.validator,
messages,
xmlFileAssetId:artifact.fileAssetId,
validationId,
};
}
}
