Files
musadeq/backend/src/modules/invoices/invoice.service.ts

200 lines
6.5 KiB
TypeScript

/**
* ════════════════════════════════════════════════════════════
* مُصادَق (Musadaq) — Invoices Service
* ════════════════════════════════════════════════════════════
*/
import {
Injectable,
NotFoundException,
ForbiddenException,
Inject,
forwardRef,
InternalServerErrorException,
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import * as path from 'path';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { Invoice, InvoiceStatus } from './entities/invoice.entity';
import { LocalStorageService } from '../../services/storage/local-storage.service';
import { SubscriptionsService } from '../subscriptions/subscription.service';
import { UBLGeneratorService } from './ubl-generator.service';
import { JoFotaraGatewayService } from './jofotara-gateway.service';
import { CompaniesService } from '../companies/company.service';
@Injectable()
export class InvoicesService {
constructor(
@InjectRepository(Invoice)
private invoiceRepository: Repository<Invoice>,
private localStorageService: LocalStorageService,
@Inject(forwardRef(() => SubscriptionsService))
private subscriptionsService: SubscriptionsService,
@InjectQueue('invoice-processing')
private invoiceQueue: Queue,
private ublGenerator: UBLGeneratorService,
private jofotaraGateway: JoFotaraGatewayService,
@Inject(forwardRef(() => CompaniesService))
private companiesService: CompaniesService,
) {}
/**
* رفع فاتورة جديدة وبدء المعالجة
*/
async upload(
tenantId: string,
companyId: string,
file: Express.Multer.File,
): Promise<Invoice> {
const canUpload = await this.subscriptionsService.checkInvoiceLimit(tenantId);
if (!canUpload) {
throw new ForbiddenException('Monthly invoice limit reached for your current plan');
}
const fileName = `${Date.now()}-${file.originalname}`;
const filePath = await this.localStorageService.saveFile(
tenantId,
companyId,
fileName,
file.buffer,
);
const invoice = this.invoiceRepository.create({
tenant_id: tenantId,
company_id: companyId,
original_file_path: filePath,
status: InvoiceStatus.UPLOADED,
});
const savedInvoice = await this.invoiceRepository.save(invoice);
await this.invoiceQueue.add('extract-data', {
invoiceId: savedInvoice.id,
tenantId,
companyId,
filePath,
});
await this.subscriptionsService.incrementInvoiceCount(tenantId);
return savedInvoice;
}
/**
* قائمة الفواتير لشركة محددة
*/
async findAll(tenantId: string, companyId: string): Promise<Invoice[]> {
return this.invoiceRepository.find({
where: { tenant_id: tenantId, company_id: companyId },
order: { created_at: 'DESC' },
});
}
/**
* قائمة جميع الفواتير للمكتب
*/
async findAllByTenant(tenantId: string): Promise<Invoice[]> {
return this.invoiceRepository.find({
where: { tenant_id: tenantId },
relations: ['company'],
order: { created_at: 'DESC' },
});
}
/**
* تفاصيل فاتورة
*/
async findOne(tenantId: string, id: string): Promise<Invoice> {
const invoice = await this.invoiceRepository.findOne({
where: { id, tenant_id: tenantId },
relations: ['lines'],
});
if (!invoice) throw new NotFoundException('Invoice not found');
return invoice;
}
/**
* تحديث بيانات الفاتورة يدوياً
*/
async update(tenantId: string, id: string, updateData: any): Promise<Invoice> {
const invoice = await this.findOne(tenantId, id);
if (invoice.status === InvoiceStatus.APPROVED || invoice.status === InvoiceStatus.SUBMITTING) {
throw new ForbiddenException('Cannot edit already approved or submitted invoice');
}
Object.assign(invoice, updateData);
return this.invoiceRepository.save(invoice);
}
/**
* إرسال الفاتورة إلى بوابة جو فوترة الحكومية
*/
async submitToJoFotara(tenantId: string, id: string): Promise<any> {
const invoice = await this.findOne(tenantId, id);
if (invoice.status !== InvoiceStatus.VALIDATED && invoice.status !== InvoiceStatus.VALIDATION_FAILED && invoice.status !== InvoiceStatus.EXTRACTED) {
throw new ForbiddenException('Invoice must be validated or extracted before submission');
}
const company = await this.companiesService.findOne(tenantId, invoice.company_id);
const credentials = await this.companiesService.getDecryptedCredentials(tenantId, invoice.company_id);
const xml = this.ublGenerator.generateXML(invoice, company);
await this.invoiceRepository.update(id, { status: InvoiceStatus.SUBMITTING });
try {
const response = await this.jofotaraGateway.submitInvoice(
xml,
credentials.clientId,
credentials.secretKey,
);
await this.invoiceRepository.update(id, { status: InvoiceStatus.APPROVED });
return response;
} catch (error) {
await this.invoiceRepository.update(id, { status: InvoiceStatus.VALIDATION_FAILED });
throw error;
}
}
/**
* حذف الفاتورة والملف التابع لها
*/
async remove(tenantId: string, id: string): Promise<void> {
const invoice = await this.findOne(tenantId, id);
// 1. Delete file if exists
if (invoice.original_file_path) {
await this.localStorageService.deleteFile(invoice.original_file_path);
}
// 2. Delete from DB (lines will be deleted via CASCADE)
await this.invoiceRepository.delete(id);
}
/**
* الحصول على الملف كـ Stream
*/
async getFile(tenantId: string, id: string): Promise<StreamableFile> {
const invoice = await this.findOne(tenantId, id);
if (!invoice.original_file_path) {
throw new NotFoundException('Invoice file not found');
}
const storageRoot = this.localStorageService['storageRoot']; // Accessing private for path resolve
const fullPath = path.join(storageRoot, invoice.original_file_path);
if (!fs.existsSync(fullPath)) {
throw new NotFoundException('File does not exist on disk');
}
const file = fs.createReadStream(fullPath);
return new StreamableFile(file);
}
}