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 { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import {
assertFiscalCompletedInput,
assertFiscalCreateTransactionInput,
assertFiscalReceiptInput,
assertFiscalStartedInput,
assertFiscalUncertainInput,
} from './fiscal-input-validation';
@Injectable()
export class FiscalService {
constructor(
private readonly database: DatabaseService,
private readonly outbox: OutboxService,
) {}
async createTransaction(input: {
organizationId: string;
locationId: string;
fiscalDeviceId: string;
checkId?: string;
paymentId?: string;
transactionType: 'sale' | 'refund' | 'cash_movement' | 'training' | 'other';
clientTransactionRef: string;
processData: Record<string, unknown>;
}) {
assertFiscalCreateTransactionInput(input);
const device = await this.database.query<any>(
`select * from fiscal_devices
where id=$1 and organization_id=$2 and location_id=$3
and status in ('configured','active','degraded')`,
[input.fiscalDeviceId, input.organizationId, input.locationId],
);
if (!device[0]) throw new DomainConflictError('fiscal.device_invalid');
const id = randomUUID();
await this.database.query(
`insert into fiscal_transactions(
id,organization_id,location_id,fiscal_device_id,check_id,payment_id,
transaction_type,status,client_transaction_ref,process_data
) values($1,$2,$3,$4,$5,$6,$7,'created',$8,$9::jsonb)`,
[
id, input.organizationId, input.locationId, input.fiscalDeviceId,
input.checkId ?? null, input.paymentId ?? null, input.transactionType,
input.clientTransactionRef, JSON.stringify(input.processData),
],
);
return { id, status: 'created' };
}
async markStarted(input: {
organizationId: string;
fiscalTransactionId: string;
providerReference: string;
tseTransactionNumber?: number;
tseStartAt?: string;
}) {
assertFiscalStartedInput(input);
const rows = await this.database.query<any>(
`update fiscal_transactions
set status='started',
provider_reference=$1,
tse_transaction_number=$2,
tse_start_at=coalesce($3::timestamptz,now()),
version=version+1
where id=$4 and organization_id=$5 and status='created'
returning id,status`,
[
input.providerReference,
input.tseTransactionNumber ?? null,
input.tseStartAt ?? null,
input.fiscalTransactionId,
input.organizationId,
],
);
if (!rows[0]) throw new DomainConflictError('fiscal.start_invalid');
return rows[0];
}
async markCompleted(input: {
organizationId: string;
fiscalTransactionId: string;
tseTransactionNumber: number;
signatureCounter: number;
tseStartAt: string;
tseEndAt: string;
verificationValue: string;
providerReference?: string;
}) {
assertFiscalCompletedInput(input);
const rows = await this.database.query<any>(
`update fiscal_transactions
set status='completed',
tse_transaction_number=$1,
tse_signature_counter=$2,
tse_start_at=$3::timestamptz,
tse_end_at=$4::timestamptz,
verification_value=$5,
provider_reference=coalesce($6,provider_reference),
completed_at=now(),
version=version+1
where id=$7 and organization_id=$8
and status in ('created','started','uncertain')
returning id,check_id,payment_id`,
[
input.tseTransactionNumber,
input.signatureCounter,
input.tseStartAt,
input.tseEndAt,
input.verificationValue,
input.providerReference ?? null,
input.fiscalTransactionId,
input.organizationId,
],
);
if (!rows[0]) throw new DomainConflictError('fiscal.complete_invalid');
await this.outbox.append({
organizationId: input.organizationId,
aggregateType: 'FiscalTransaction',
aggregateId: input.fiscalTransactionId,
eventType: 'FiscalTransactionCompleted',
payload: {
fiscalTransactionId: input.fiscalTransactionId,
tseTransactionNumber: input.tseTransactionNumber,
signatureCounter: input.signatureCounter,
},
});
return { id: input.fiscalTransactionId, status: 'completed' };
}
async markUncertain(input: {
organizationId: string;
fiscalTransactionId: string;
providerStatus?: string;
}) {
assertFiscalUncertainInput(input);
const rows = await this.database.query<any>(
`update fiscal_transactions
set status='uncertain',
uncertain_since=coalesce(uncertain_since,now()),
provider_status=$1,
version=version+1
where id=$2 and organization_id=$3
and status not in ('completed','failed','cancelled')
returning id,status`,
[input.providerStatus ?? null, input.fiscalTransactionId, input.organizationId],
);
if (!rows[0]) throw new DomainConflictError('fiscal.uncertain_invalid');
await this.outbox.append({
organizationId: input.organizationId,
aggregateType: 'FiscalTransaction',
aggregateId: input.fiscalTransactionId,
eventType: 'FiscalTransactionUncertain',
payload: { fiscalTransactionId: input.fiscalTransactionId },
});
return rows[0];
}
async createReceiptSnapshot(input: {
organizationId: string;
fiscalTransactionId: string;
receiptNumber: string;
issuerName: string;
issuerAddress: string;
locale: string;
qrPayload?: string;
receiptPayload: Record<string, unknown>;
}) {
assertFiscalReceiptInput(input);
const tx = await this.database.query<any>(
`select
ft.*, fd.system_serial_number, fd.tse_serial_number
from fiscal_transactions ft
join fiscal_devices fd on fd.id=ft.fiscal_device_id
where ft.id=$1 and ft.organization_id=$2 and ft.status='completed'`,
[input.fiscalTransactionId, input.organizationId],
);
if (!tx[0]) throw new DomainConflictError('fiscal.receipt_requires_completed_transaction');
const id = randomUUID();
await this.database.query(
`insert into fiscal_receipt_snapshots(
id,fiscal_transaction_id,check_id,receipt_number,
issuer_name,issuer_address,issued_at,
transaction_start_at,transaction_end_at,tse_transaction_number,
system_serial_number,tse_serial_number,signature_counter,
verification_value,qr_payload,receipt_payload,locale
)
values($1,$2,$3,$4,$5,$6,now(),$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb,$16)`,
[
id,
input.fiscalTransactionId,
tx[0].check_id,
input.receiptNumber,
input.issuerName,
input.issuerAddress,
tx[0].tse_start_at,
tx[0].tse_end_at,
tx[0].tse_transaction_number,
tx[0].system_serial_number,
tx[0].tse_serial_number,
tx[0].tse_signature_counter,
tx[0].verification_value,
input.qrPayload ?? null,
JSON.stringify(input.receiptPayload),
input.locale,
],
);
return { id };
}
}
