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 { FiscalProviderRegistry } from '../providers/fiscal-provider.registry';
import {
assertFiscalIntegrationEnum,
assertFiscalIntegrationRecord,
assertFiscalIntegrationScope,
assertFiscalIntegrationText,
assertOptionalFiscalIntegrationText,
normalizeFiscalEnvironment,
} from './fiscal-integration-input-validation';
@Injectable()
export class FiscalIntegrationService {
constructor(
private readonly database:DatabaseService,
private readonly providers:FiscalProviderRegistry,
) {}
async createConnection(input:unknown) {
assertFiscalIntegrationScope(input, 'fiscal.integration_input_invalid', { locationRequired: false });
assertFiscalIntegrationText(input.provider, 100, 'fiscal.integration_input_invalid');
const providerKey = input.provider;
assertOptionalFiscalIntegrationText(input.displayName, 250, 'fiscal.integration_input_invalid');
assertFiscalIntegrationText(input.credentialReference, 500, 'fiscal.integration_input_invalid');
assertOptionalFiscalIntegrationText(input.userId, 200, 'fiscal.integration_input_invalid');
const config = input.config ?? {};
assertFiscalIntegrationRecord(config, 'fiscal.integration_input_invalid');
const environment = normalizeFiscalEnvironment(input.environment, 'fiscal.integration_input_invalid');
const outageMode = input.fiscalOutageMode ?? 'receipt_with_failure_note';
assertFiscalIntegrationEnum(outageMode, ['block', 'receipt_with_failure_note', 'provider_offline_buffer'], 'fiscal.integration_input_invalid');
const provider=this.providers.get(providerKey);
const id=randomUUID();
await this.database.query(
`insert into integration_connections(
id,organization_id,location_id,category,provider,display_name,status,
credential_reference,credentials_json,config_json,sync_direction,
created_by_user_id,environment,fiscal_outage_mode
) values($1,$2,$3,'fiscal',$4,$5,'draft',$6,'{}'::jsonb,$7::jsonb,
'bidirectional',$8,$9,$10)`,
[
id,input.organizationId,input.locationId??null,providerKey,
input.displayName??providerKey,input.credentialReference,
JSON.stringify(config),input.userId??null,
environment,outageMode,
],
);
return {id,status:'draft',provider:providerKey,capabilities:provider.capabilities};
}
async load(organizationId:string,id:string) {
const rows=await this.database.query<any>(
`select * from integration_connections
where id=$1 and organization_id=$2 and category='fiscal'`,
[id,organizationId],
);
if(!rows[0]) throw new DomainConflictError('fiscal.connection_not_found');
return rows[0];
}
context(c:any,mapping:any) {
return {
connectionId:c.id,
organizationId:c.organization_id,
locationId:mapping.location_id,
environment:(c.environment??'sandbox') as 'sandbox'|'production',
credentialReference:c.credential_reference,
config:c.config_json??{},
providerDeviceId:mapping.provider_device_id,
providerClientId:mapping.provider_client_id,
};
}
async mapDevice(input:unknown) {
assertFiscalIntegrationScope(input, 'fiscal.mapping_input_invalid');
assertFiscalIntegrationText(input.connectionId, 200, 'fiscal.mapping_input_invalid');
const connectionId = input.connectionId;
assertFiscalIntegrationText(input.providerDeviceId, 500, 'fiscal.mapping_input_invalid');
assertFiscalIntegrationText(input.providerClientId, 500, 'fiscal.mapping_input_invalid');
const organizationId = input.organizationId as string;
const locationId = input.locationId as string;
for (const key of ['fiscalDeviceId', 'cashRegisterId', 'providerDeviceSerial', 'providerClientSerial']) {
assertOptionalFiscalIntegrationText(input[key], 500, 'fiscal.mapping_input_invalid');
}
assertOptionalFiscalIntegrationText(input.displayName, 250, 'fiscal.mapping_input_invalid');
const capabilities = input.capabilities ?? {};
assertFiscalIntegrationRecord(capabilities, 'fiscal.mapping_input_invalid');
const connection=await this.load(organizationId,connectionId);
if(connection.location_id && connection.location_id!==locationId) {
throw new DomainConflictError('fiscal.connection_location_mismatch');
}
const id=randomUUID();
await this.database.query(
`insert into fiscal_device_mappings(
id,organization_id,integration_connection_id,location_id,fiscal_device_id,
cash_register_id,provider_device_id,provider_client_id,provider_device_serial,
provider_client_serial,display_name,status,capabilities_json
) values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'unknown',$12::jsonb)`,
[
id,organizationId,connectionId,locationId,input.fiscalDeviceId??null,
input.cashRegisterId??null,input.providerDeviceId,input.providerClientId,
input.providerDeviceSerial??null,input.providerClientSerial??null,
input.displayName??'Fiscal device',JSON.stringify(capabilities),
],
);
return {id,status:'unknown'};
}
async test(input:{organizationId:string;connectionId:string;mappingId:string}) {
const connection=await this.load(input.organizationId,input.connectionId);
const mappings=await this.database.query<any>(
`select * from fiscal_device_mappings
where id=$1 and organization_id=$2 and integration_connection_id=$3`,
[input.mappingId,input.organizationId,input.connectionId],
);
if(!mappings[0]) throw new DomainConflictError('fiscal.mapping_not_found');
const provider=this.providers.get(connection.provider);
const started=Date.now();
const health=await provider.healthCheck(this.context(connection,mappings[0]));
const device=health.ok
? await provider.getDeviceStatus(this.context(connection,mappings[0]))
: {status:'unknown' as const};
const latency=Date.now()-started;
await this.database.query(
`update fiscal_device_mappings
set status=$1,provider_device_serial=coalesce($2,provider_device_serial),
provider_client_serial=coalesce($3,provider_client_serial),
last_seen_at=case when $1='available' then now() else last_seen_at end,
updated_at=now(),version=version+1
where id=$4`,
[device.status,device.providerDeviceSerial??null,device.providerClientSerial??null,input.mappingId],
);
await this.database.query(
`update integration_connections
set status=$1,last_health_check_at=now(),last_health_ok=$2,
last_error_at=case when $2 then null else now() end,
last_error_code=case when $2 then null else $3 end,
updated_at=now()
where id=$4`,
[health.ok && device.status==='available'?'connected':'degraded',
health.ok,health.code??(device.status==='available'?null:`device_${device.status}`),
input.connectionId],
);
await this.database.query(
`insert into fiscal_provider_health_history(
id,organization_id,integration_connection_id,ok,status_code,code,latency_ms
) values($1,$2,$3,$4,$5,$6,$7)`,
[
randomUUID(),input.organizationId,input.connectionId,
health.ok && device.status==='available',health.status??null,
health.code??(device.status==='available'?null:`device_${device.status}`),latency,
],
);
return {health,device,latencyMs:latency};
}
async enableProduction(input:{organizationId:string;connectionId:string}) {
const c=await this.load(input.organizationId,input.connectionId);
if(c.environment!=='production') {
throw new DomainConflictError('fiscal.production_environment_required');
}
if(!c.last_health_ok) throw new DomainConflictError('fiscal.production_health_required');
const mappings=await this.database.query<any>(
`select count(*)::int as total,
count(*) filter(where status='available')::int as available
from fiscal_device_mappings
where integration_connection_id=$1 and effective_until is null and status<>'disabled'`,
[input.connectionId],
);
if(Number(mappings[0]?.total??0)===0 ||
Number(mappings[0]?.available??0)!==Number(mappings[0]?.total??0)) {
throw new DomainConflictError('fiscal.production_mapping_required');
}
await this.database.query(
`update integration_connections
set status='connected',fiscal_production_enabled_at=now(),updated_at=now()
where id=$1`,
[input.connectionId],
);
return {enabled:true};
}
}
