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 {
assertFiscalExportCreateInput,
assertFiscalPeriodInput,
} from '../application/fiscal-input-validation';
@Injectable()
export class FiscalExportService {
constructor(private readonly database: DatabaseService) {}
async createJob(input: {
organizationId: string;
locationId: string;
exportType: 'dsfinvk' | 'tse' | 'cash_journal';
periodStart: string;
periodEnd: string;
userId?: string;
}) {
assertFiscalExportCreateInput(input);
const id = randomUUID();
await this.database.query(
`insert into fiscal_export_jobs(
id, organization_id, location_id, export_type,
period_start, period_end, created_by
) values($1,$2,$3,$4,$5::date,$6::date,$7)`,
[
id,
input.organizationId,
input.locationId,
input.exportType,
input.periodStart,
input.periodEnd,
input.userId ?? null,
],
);
return { id, status: 'pending' };
}
async buildCashJournal(input: {
organizationId: string;
locationId: string;
periodStart: string;
periodEnd: string;
}) {
assertFiscalPeriodInput(input);
const rows = await this.database.query<any>(
`select
cs.business_date,
t.code as till_code,
cm.movement_type,
cm.amount_minor,
cm.currency_code,
cm.reason,
cm.source_type,
cm.source_id,
cm.occurred_at
from cash_movements cm
join cash_shifts cs on cs.id=cm.cash_shift_id
join tills t on t.id=cs.till_id
where cm.organization_id=$1
and cm.location_id=$2
and cs.business_date between $3::date and $4::date
order by cs.business_date, cm.occurred_at`,
[
input.organizationId,
input.locationId,
input.periodStart,
input.periodEnd,
],
);
return {
exportType: 'cash_journal',
periodStart: input.periodStart,
periodEnd: input.periodEnd,
rows,
};
}
async buildDsfinvkBoundary(input: {
organizationId: string;
locationId: string;
periodStart: string;
periodEnd: string;
}) {
assertFiscalPeriodInput(input);
const tx = await this.database.query<any>(
`select
ft.id,
ft.transaction_type,
ft.status,
ft.client_transaction_ref,
ft.tse_transaction_number,
ft.tse_signature_counter,
ft.tse_start_at,
ft.tse_end_at,
ft.verification_value,
fd.system_serial_number,
fd.tse_serial_number,
frs.receipt_number,
frs.receipt_payload
from fiscal_transactions ft
join fiscal_devices fd on fd.id=ft.fiscal_device_id
left join fiscal_receipt_snapshots frs on frs.fiscal_transaction_id=ft.id
where ft.organization_id=$1
and ft.location_id=$2
and ft.created_at::date between $3::date and $4::date
order by ft.created_at`,
[
input.organizationId,
input.locationId,
input.periodStart,
input.periodEnd,
],
);
return {
exportType: 'dsfinvk',
version: 'provider-current-required',
conformanceStatus: 'boundary-only',
records: tx,
};
}
async buildTseExportBoundary(input: {
organizationId: string;
locationId: string;
periodStart: string;
periodEnd: string;
}) {
assertFiscalPeriodInput(input);
const devices = await this.database.query<any>(
`select *
from fiscal_devices
where organization_id=$1
and location_id=$2
and status<>'retired'`,
[input.organizationId, input.locationId],
);
if (devices.length === 0) {
throw new DomainConflictError('fiscal.export_no_device');
}
return {
exportType: 'tse',
periodStart: input.periodStart,
periodEnd: input.periodEnd,
providerManaged: true,
devices: devices.map((d) => ({
fiscalDeviceId: d.id,
providerKey: d.provider_key,
systemSerialNumber: d.system_serial_number,
tseSerialNumber: d.tse_serial_number,
})),
};
}
}
