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 {
assertAccountingDate,
assertAccountingEnum,
assertAccountingRecord,
assertAccountingScope,
assertAccountingText,
assertOptionalAccountingText,
} from './accounting-input-validation';
@Injectable()
export class AccountingExportService {
constructor(private readonly database:DatabaseService) {}
async create(input:unknown) {
assertAccountingScope(input, 'accounting.export_input_invalid', { locationRequired: false });
assertAccountingText(input.exportType, 100, 'accounting.export_input_invalid');
assertAccountingDate(input.periodStart, 'accounting.export_input_invalid');
assertAccountingDate(input.periodEnd, 'accounting.export_input_invalid');
if(input.periodEnd<input.periodStart) throw new DomainConflictError('accounting.export_period_invalid');
const options = input.options ?? input.config ?? {};
assertAccountingRecord(options, 'accounting.export_input_invalid');
assertOptionalAccountingText(input.userId, 200, 'accounting.export_input_invalid');
assertOptionalAccountingText(input.connectionId, 200, 'accounting.export_input_invalid');
assertAccountingEnum(input.exportType, ['datev_csv', 'journal_csv', 'invoice_xml', 'invoice_pdf', 'provider_transfer'], 'accounting.export_input_invalid');
const id=randomUUID();
await this.database.query(
`insert into accounting_export_jobs(
id,organization_id,location_id,integration_connection_id,export_type,
period_start,period_end,status,requested_by_user_id,options_json
) values($1,$2,$3,$4,$5,$6,$7,'queued',$8,$9::jsonb)`,
[
id,input.organizationId,input.locationId??null,input.connectionId??null,
input.exportType,input.periodStart,input.periodEnd,input.userId??null,
JSON.stringify(options),
],
);
return {id,status:'queued'};
}
async buildRows(jobId:string) {
assertAccountingText(jobId, 200, 'accounting.export_input_invalid');
const jobs=await this.database.query<any>(`select * from accounting_export_jobs where id=$1`,[jobId]);
const job=jobs[0];
if(!job) throw new DomainConflictError('accounting.export_not_found');
await this.database.query(`delete from accounting_export_rows where accounting_export_job_id=$1`,[job.id]);
const config=await this.database.query<any>(
`select * from accounting_configurations
where organization_id=$1 and (location_id=$2 or location_id is null)
order by case when location_id=$2 then 0 else 1 end limit 1`,
[job.organization_id,job.location_id],
);
if(!config[0]) throw new DomainConflictError('accounting.config_missing');
const rows=await this.database.query<any>(
`select p.id,p.amount_minor,p.currency_code,p.created_at,
t.tender_type,p.check_id
from payments p
join tenders t on t.id=p.tender_id
where p.organization_id=$1
and ($2::uuid is null or p.location_id=$2)
and p.status in ('captured','refunded')
and p.created_at::date between $3 and $4
order by p.created_at,p.id`,
[job.organization_id,job.location_id,job.period_start,job.period_end],
);
let n=1;
for(const row of rows) {
const rule=await this.database.query<any>(
`select *
from accounting_rules
where organization_id=$1
and (location_id=$2 or location_id is null)
and rule_type='payment' and is_active=true
and (
match_json='{}'::jsonb
or match_json->>'tenderType'=$3
)
order by priority,case when location_id=$2 then 0 else 1 end
limit 1`,
[job.organization_id,job.location_id,row.tender_type],
);
const debit=rule[0]?.debit_account
?? (row.tender_type==='cash'?config[0].default_cash_account:config[0].default_card_clearing_account);
const credit=rule[0]?.credit_account??config[0].default_revenue_account;
if(!debit || !credit) {
throw new DomainConflictError('accounting.rule_missing',{sourceId:row.id,tenderType:row.tender_type});
}
await this.database.query(
`insert into accounting_export_rows(
id,accounting_export_job_id,row_number,source_type,source_id,
debit_account,credit_account,amount_minor,currency_code,tax_key,
booking_text,booking_date,reference,payload_json
) values($1,$2,$3,'payment',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb)`,
[
randomUUID(),job.id,n++,row.id,debit,credit,row.amount_minor,row.currency_code,
rule[0]?.tax_key??null,'Gastro OS payment',
new Date(row.created_at).toISOString().slice(0,10),row.check_id??row.id,
JSON.stringify({tenderType:row.tender_type}),
],
);
}
return {rows:rows.length};
}
async list(input:{organizationId:string;locationId?:string}) {
assertAccountingScope(input, 'accounting.export_input_invalid', { locationRequired: false });
return this.database.query<any>(
`select * from accounting_export_jobs
where organization_id=$1 and ($2::uuid is null or location_id=$2)
order by created_at desc limit 100`,
[input.organizationId,input.locationId??null],
);
}
}
