200 lines
6.5 KiB
TypeScript
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);
|
|
}
|
|
}
|