import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import {
assertCreditNoteInput,
assertInvoiceFromCheckInput,
assertInvoiceReferenceInput,
} from './invoice-input-validation';
@Injectable()
export class InvoiceService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
) {}
async createFromCheck(input:{
organizationId:string;locationId:string;checkId:string;
customer:any;userId?:string;dueDate?:string;notes?:string;
}) {
assertInvoiceFromCheckInput(input);
return this.database.transaction(async(tx)=>{
const check=await tx.query<any>(
`select * from checks
where id=$1 and organization_id=$2 and location_id=$3
for update`,
[input.checkId,input.organizationId,input.locationId],
);
if(!check[0]) throw new DomainConflictError('invoice.check_not_found');
const lines=await tx.query<any>(
`select ci.*,p.name as product_name
from check_items ci
left join products p on p.id=ci.product_id
where ci.check_id=$1 and ci.status<>'voided'
order by ci.created_at,ci.id`,
[input.checkId],
);
if(!lines.length) throw new DomainConflictError('invoice.no_lines');
const customerId=randomUUID();
await tx.query(
`insert into invoice_customers(
id,organization_id,name,email,vat_id,tax_number,buyer_reference,
electronic_address,address_json,locale
) values($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10)`,
[
customerId,input.organizationId,input.customer.name,input.customer.email??null,
input.customer.vatId??null,input.customer.taxNumber??null,
input.customer.buyerReference??null,input.customer.electronicAddress??null,
JSON.stringify(input.customer.address??{}),input.customer.locale??'de-DE',
],
);
let net=0n,tax=0n,gross=0n;
const normalized:any[]=[];
for(const [i,line] of lines.entries()) {
const lineGross=BigInt(line.gross_minor ?? line.total_minor ?? 0);
const lineTax=BigInt(line.tax_minor ?? 0);
const lineNet=lineGross-lineTax;
net+=lineNet; tax+=lineTax; gross+=lineGross;
normalized.push({
lineNumber:i+1,
description:line.product_name??line.description??'Item',
quantity:String(line.quantity??1),
unitCode:line.unit_code??null,
unitPriceNetMinor:lineNet.toString(),
netAmountMinor:lineNet.toString(),
taxRateBasisPoints:Number(line.tax_rate_basis_points??0),
taxAmountMinor:lineTax.toString(),
grossAmountMinor:lineGross.toString(),
sourceEntityId:line.id,
});
}
if(gross!==BigInt(check[0].gross_total_minor??gross)) {
throw new DomainConflictError('invoice.check_total_mismatch');
}
const invoiceId=randomUUID();
await tx.query(
`insert into invoices(
id,organization_id,location_id,check_id,invoice_customer_id,document_type,status,
currency_code,gross_total_minor,net_total_minor,tax_total_minor,buyer_reference,
due_date,notes,source_snapshot_json,created_by_user_id,e_invoice_status
) values($1,$2,$3,$4,$5,'invoice','draft',$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,'not_requested')`,
[
invoiceId,input.organizationId,input.locationId,input.checkId,customerId,
check[0].currency_code,gross.toString(),net.toString(),tax.toString(),
input.customer.buyerReference??null,input.dueDate??null,input.notes??null,
JSON.stringify({
checkId:input.checkId,
checkVersion:check[0].version,
grossTotalMinor:String(check[0].gross_total_minor??gross),
currencyCode:check[0].currency_code,
}),
input.userId??null,
],
);
for(const line of normalized) {
await tx.query(
`insert into invoice_lines(
id,invoice_id,line_number,description,quantity,unit_code,unit_price_net_minor,
net_amount_minor,tax_rate_basis_points,tax_amount_minor,gross_amount_minor,
source_entity_type,source_entity_id
) values($1,$2,$3,$4,$5::numeric,$6,$7,$8,$9,$10,$11,'check_item',$12)`,
[
randomUUID(),invoiceId,line.lineNumber,line.description,line.quantity,line.unitCode,
line.unitPriceNetMinor,line.netAmountMinor,line.taxRateBasisPoints,
line.taxAmountMinor,line.grossAmountMinor,line.sourceEntityId,
],
);
}
await tx.query(
`insert into invoice_tax_summaries(invoice_id,tax_rate_basis_points,taxable_net_minor,tax_minor,gross_minor)
select invoice_id,tax_rate_basis_points,sum(net_amount_minor),sum(tax_amount_minor),sum(gross_amount_minor)
from invoice_lines where invoice_id=$1 group by invoice_id,tax_rate_basis_points`,
[invoiceId],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Invoice',
aggregateId:invoiceId,
eventType:'InvoiceDraftCreated',
payload:{invoiceId,checkId:input.checkId},
},tx);
return {id:invoiceId,status:'draft'};
});
}
async createCreditNote(input:{
organizationId:string;locationId:string;originalInvoiceId:string;
reason:string;userId?:string;
}) {
assertCreditNoteInput(input);
return this.database.transaction(async(tx)=>{
const original=await tx.query<any>(
`select * from invoices
where id=$1 and organization_id=$2 and location_id=$3
and document_type='invoice' and status='finalized'
for update`,
[input.originalInvoiceId,input.organizationId,input.locationId],
);
if(!original[0]) throw new DomainConflictError('credit_note.original_invalid');
const existing=await tx.query<any>(
`select id from invoices
where original_invoice_id=$1 and document_type='credit_note'
and status in ('draft','validated','finalized')
limit 1`,
[input.originalInvoiceId],
);
if(existing[0]) throw new DomainConflictError('credit_note.already_exists');
const id=randomUUID();
await tx.query(
`insert into invoices(
id,organization_id,location_id,check_id,invoice_customer_id,document_type,status,
currency_code,gross_total_minor,net_total_minor,tax_total_minor,buyer_reference,
payment_reference,payment_terms_text,notes,source_snapshot_json,
original_invoice_id,created_by_user_id,e_invoice_status
)
select $1,organization_id,location_id,check_id,invoice_customer_id,'credit_note','draft',
currency_code,-gross_total_minor,-net_total_minor,-tax_total_minor,buyer_reference,
payment_reference,payment_terms_text,$2,
jsonb_build_object('originalInvoiceId',id,'originalInvoiceNumber',invoice_number),
id,$3,'not_requested'
from invoices where id=$4`,
[id,input.reason,input.userId??null,input.originalInvoiceId],
);
await tx.query(
`insert into invoice_lines(
id,invoice_id,line_number,description,quantity,unit_code,unit_price_net_minor,
net_amount_minor,tax_rate_basis_points,tax_amount_minor,gross_amount_minor,
accounting_revenue_account,source_entity_type,source_entity_id
)
select gen_random_uuid(),$1,line_number,description,-quantity,unit_code,-unit_price_net_minor,
-net_amount_minor,tax_rate_basis_points,-tax_amount_minor,-gross_amount_minor,
accounting_revenue_account,'invoice_line',id
from invoice_lines where invoice_id=$2
order by line_number`,
[id,input.originalInvoiceId],
);
await tx.query(
`insert into invoice_tax_summaries(invoice_id,tax_rate_basis_points,taxable_net_minor,tax_minor,gross_minor)
select $1,tax_rate_basis_points,-taxable_net_minor,-tax_minor,-gross_minor
from invoice_tax_summaries where invoice_id=$2`,
[id,input.originalInvoiceId],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Invoice',
aggregateId:id,
eventType:'CreditNoteDraftCreated',
payload:{creditNoteId:id,originalInvoiceId:input.originalInvoiceId},
},tx);
return {id,status:'draft'};
});
}
async finalize(input:{organizationId:string;locationId:string;invoiceId:string;userId?:string}) {
assertInvoiceReferenceInput(input);
return this.database.transaction(async(tx)=>{
const inv=await tx.query<any>(
`select i.*,ac.id as accounting_configuration_id,
ac.invoice_number_prefix,ac.credit_note_number_prefix,
ac.next_invoice_number,ac.next_credit_note_number
from invoices i
join accounting_configurations ac
on ac.organization_id=i.organization_id
and (ac.location_id=i.location_id or ac.location_id is null)
where i.id=$1 and i.organization_id=$2 and i.location_id=$3
and i.status in ('draft','validated')
order by case when ac.location_id=i.location_id then 0 else 1 end
limit 1
for update of i,ac`,
[input.invoiceId,input.organizationId,input.locationId],
);
if(!inv[0]) throw new DomainConflictError('invoice.finalize_invalid');
const row=inv[0];
const isCredit=row.document_type==='credit_note';
const seq=BigInt(isCredit?row.next_credit_note_number:row.next_invoice_number);
const prefix=isCredit?row.credit_note_number_prefix:row.invoice_number_prefix;
const number=`${prefix}-${String(seq).padStart(6,'0')}`;
await tx.query(
`update invoices
set invoice_number=$1,status='finalized',issue_date=current_date,
finalized_at=now(),version=version+1
where id=$2`,
[number,row.id],
);
await tx.query(
`update accounting_configurations
set next_invoice_number=case when $1 then next_invoice_number else next_invoice_number+1 end,
next_credit_note_number=case when $1 then next_credit_note_number+1 else next_credit_note_number end,
updated_at=now(),version=version+1
where id=$2`,
[isCredit,row.accounting_configuration_id],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Invoice',
aggregateId:row.id,
eventType:isCredit?'CreditNoteFinalized':'InvoiceFinalized',
payload:{invoiceId:row.id,invoiceNumber:number,originalInvoiceId:row.original_invoice_id??null},
},tx);
return {id:row.id,invoiceNumber:number,status:'finalized'};
});
}
async cancelDraft(input:{organizationId:string;locationId:string;invoiceId:string;userId?:string}) {
assertInvoiceReferenceInput(input);
const rows=await this.database.query<any>(
`update invoices
set status='cancelled',cancelled_at=now(),version=version+1
where id=$1 and organization_id=$2 and location_id=$3
and status in ('draft','validated')
returning id,status`,
[input.invoiceId,input.organizationId,input.locationId],
);
if(!rows[0]) throw new DomainConflictError('invoice.cancel_invalid');
return rows[0];
}
}
