Proformas preview

This commit is contained in:
David Arranz 2026-07-07 15:29:30 +02:00
parent cc14cdacf1
commit 588e9dd733
61 changed files with 1004 additions and 719 deletions

View File

@ -39,6 +39,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@erp/companies": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/customers": "workspace:*",

View File

@ -4,6 +4,7 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { IProformaRepository } from "../repositories";
import type {
ICompanyReportProfileFinder,
IProformaCreator,
IProformaFinder,
IProformaFullReadModelAssembler,
@ -24,7 +25,8 @@ import {
GetProformaByIdUseCase,
IssueProformaUseCase,
ListProformasUseCase,
ReportProformaUseCase,
PreviewProformaReportUseCase,
ReportProformaPdfUseCase,
UpdateProformaByIdUseCase,
} from "../use-cases";
@ -54,16 +56,38 @@ export function buildListProformasUseCase(deps: {
);
}
export function buildReportProformaUseCase(deps: {
export function buildReportProformaPdfUseCase(deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new ReportProformaUseCase({
return new ReportProformaPdfUseCase({
finder: deps.finder,
companyReportProfileFinder: deps.companyReportProfileFinder,
fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
reportSnapshotBuilder: deps.reportSnapshotBuilder,
documentService: deps.documentService,
transactionManager: deps.transactionManager,
});
}
export function buildPreviewProformaReportUseCase(deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new PreviewProformaReportUseCase({
finder: deps.finder,
companyReportProfileFinder: deps.companyReportProfileFinder,
fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
reportSnapshotBuilder: deps.reportSnapshotBuilder,

View File

@ -0,0 +1,15 @@
export class CompanyReportProfileNotFoundError extends Error {
public readonly code = "COMPANY_REPORT_PROFILE_NOT_FOUND";
public constructor(companyId: string) {
super(
`Cannot generate the proforma report because the company document profile is missing for company ${companyId}.`
);
this.name = "CompanyReportProfileNotFoundError";
}
}
export const isCompanyReportProfileNotFoundError = (
error: unknown
): error is CompanyReportProfileNotFoundError =>
error instanceof CompanyReportProfileNotFoundError;

View File

@ -0,0 +1 @@
export * from "./company-report-profile-not-found.error";

View File

@ -1,4 +1,5 @@
export * from "./di";
export * from "./errors";
export * from "./mappers";
export * from "./models";
export * from "./repositories";

View File

@ -0,0 +1,30 @@
import type { Maybe } from "@repo/rdx-utils";
export type CompanyReportLogo = {
url: string;
};
export type CompanyReportAddress = {
street: string;
street2: Maybe<string>;
postalCode: Maybe<string>;
city: Maybe<string>;
province: Maybe<string>;
country: Maybe<string>;
};
export type CompanyReportProfile = {
companyId: string;
slug: string;
legalName: string;
tradeName: Maybe<string>;
tin: Maybe<string>;
email: Maybe<string>;
phone: Maybe<string>;
website: Maybe<string>;
locale: Maybe<string>;
currencyCode: Maybe<string>;
logo: Maybe<CompanyReportLogo>;
address: Maybe<CompanyReportAddress>;
version: Maybe<string>;
};

View File

@ -1,5 +1,7 @@
export * from "./company-report-profile.model";
export * from "./proforma-create-input.model";
export * from "./proforma-full-read.model";
export * from "./proforma-item-tax-codes-input.model";
export * from "./proforma-summary";
export * from "./proforma-update-input.model";
export * from "./report-localization.model";

View File

@ -0,0 +1,5 @@
export type ReportLocalization = {
languageCode: string;
locale: string;
currencyCode: string;
};

View File

@ -0,0 +1,11 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { CompanyReportProfile } from "../models";
export interface ICompanyReportProfileFinder {
findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<CompanyReportProfile>, Error>>;
}

View File

@ -1,8 +1,8 @@
export * from "./assemblers";
export * from "./catalog-resolver";
export * from "./company-report-profile-finder";
export * from "./proforma-creator";
export * from "./proforma-document-generator.interface";
export * from "./proforma-document-metadata-factory";
export * from "./proforma-document-properties-factory";
export * from "./proforma-finder";
export * from "./proforma-issuer";

View File

@ -1,47 +0,0 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../snapshot-builders";
/**
* Construye los metadatos del documento PDF de una factura emitida.
*
* - Application-level
* - Determinista
* - Sin IO
*/
export class ProformaDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
if (!snapshot.id) {
throw new Error("ProformaReportSnapshot.id is required");
}
if (!snapshot.company_id) {
throw new Error("ProformaReportSnapshot.companyId is required");
}
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company_slug,
format: "PDF",
languageCode: snapshot.language_code ?? "es",
filename: this.buildFilename(snapshot),
storageKey: this.buildCacheKey(snapshot),
};
}
private buildFilename(snapshot: ProformaReportSnapshot): string {
// Ejemplo: factura-F2024-000123-FULANITO.pdf
return `factura-${snapshot.series}${snapshot.invoice_number}-${snapshot.recipient.name}.pdf`;
}
private buildCacheKey(snapshot: ProformaReportSnapshot): string {
// Versionado explícito para invalidaciones futuras
return ["proforma", snapshot.company_id, snapshot.series, snapshot.invoice_number, "v1"].join(
":"
);
}
}

View File

@ -14,9 +14,9 @@ export class ProformaDocumentPropertiesFactory
{
build(snapshot: ProformaReportSnapshot): IDocumentProperties {
return {
title: snapshot.reference,
title: snapshot.reference ?? `Proforma ${snapshot.series}${snapshot.invoice_number}`,
subject: "proforma",
author: snapshot.company_slug,
author: snapshot.company.legalName,
creator: "FactuGES ERP",
};
}

View File

@ -2,6 +2,5 @@ export * from "./proforma-items-report-snapshot-builder";
export * from "./proforma-report-item-snapshot.interface";
export * from "./proforma-report-snapshot.interface";
export * from "./proforma-report-snapshot-builder";
export * from "./proforma-report-snapshot-builder";
export * from "./proforma-report-tax-snapshot.interface";
export * from "./proforma-tax-report-snapshot-builder";

View File

@ -2,18 +2,21 @@ import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/cor
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
type ProformaItemReportSnapshotBuilderParams = ISnapshotBuilderParams & {
locale: string;
};
export interface IProformaItemReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot["items"], ProformaReportItemSnapshot[]> {}
export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSnapshotBuilder {
toOutput(
items: ProformaFullSnapshot["items"],
params?: ISnapshotBuilderParams
params?: ProformaItemReportSnapshotBuilderParams
): ProformaReportItemSnapshot[] {
const locale = params?.locale as string;
const locale = params?.locale ?? "es-ES";
const moneyOptions = {
hideZeros: true,
@ -21,14 +24,18 @@ export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSna
};
return items.map((item) => ({
description: item.description,
quantity: QuantityDTOHelper.format(item.quantity, locale, { minimumFractionDigits: 0 }),
unit_amount: MoneyDTOHelper.format(item.unit_amount, locale, moneyOptions),
description: item.description ?? "",
quantity: item.quantity
? QuantityDTOHelper.format(item.quantity, locale, { minimumFractionDigits: 0 })
: "",
unit_amount: item.unit_amount ? MoneyDTOHelper.format(item.unit_amount, locale, moneyOptions) : "",
subtotal_amount: MoneyDTOHelper.format(item.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(item.discount_percentage, locale, {
minimumFractionDigits: 0,
}),
discount_amount: MoneyDTOHelper.format(item.discount_amount, locale, moneyOptions),
discount_percentage: item.item_discount_percentage
? PercentageDTOHelper.format(item.item_discount_percentage, locale, {
minimumFractionDigits: 0,
})
: "",
discount_amount: MoneyDTOHelper.format(item.item_discount_amount, locale, moneyOptions),
taxable_amount: MoneyDTOHelper.format(item.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(item.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(item.total_amount, locale, moneyOptions),

View File

@ -2,12 +2,17 @@ import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import { DateHelper } from "@repo/rdx-utils";
import type { CompanyReportProfile, ReportLocalization } from "../../models";
import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
import type { ProformaReportSnapshot } from "./proforma-report-snapshot.interface";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
export type ProformaReportSnapshotBuilderParams = ISnapshotBuilderParams & {
company: CompanyReportProfile;
localization: ReportLocalization;
};
export interface IProformaReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot, ProformaReportSnapshot> {}
@ -25,9 +30,20 @@ export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBui
toOutput(
snapshot: ProformaFullSnapshot,
params?: ISnapshotBuilderParams
params?: ProformaReportSnapshotBuilderParams
): ProformaReportSnapshot {
const locale = params?.locale as string;
const localization = params?.localization;
const company = params?.company;
if (!localization) {
throw new Error("Report localization is required");
}
if (!company) {
throw new Error("Company report profile is required");
}
const { locale } = localization;
const moneyOptions = {
hideZeros: true,
@ -37,22 +53,23 @@ export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBui
return {
id: snapshot.id,
company_id: snapshot.company_id,
company_slug: "rodax",
invoice_number: snapshot.invoice_number,
series: snapshot.series,
status: snapshot.status,
reference: snapshot.reference,
localization,
company,
language_code: snapshot.language_code,
currency_code: snapshot.currency_code,
invoice_date: DateHelper.format(snapshot.invoice_date, locale),
payment_method: snapshot.payment_method?.payment_description ?? "",
payment_method: snapshot.payment_method?.description ?? snapshot.payment_method?.name ?? "",
notes: snapshot.notes,
recipient: {
name: snapshot.recipient.name,
name: snapshot.recipient.name ?? "",
tin: snapshot.recipient.tin,
format_address: this.formatAddress(snapshot.recipient),
},

View File

@ -1,25 +1,28 @@
import type { CompanyReportProfile, ReportLocalization } from "../../models";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
export interface ProformaReportSnapshot {
id: string;
company_id: string;
company_slug: string;
invoice_number: string;
series: string;
series: string | null;
status: string;
reference: string;
reference: string | null;
localization: ReportLocalization;
company: CompanyReportProfile;
language_code: string;
currency_code: string;
invoice_date: string;
payment_method: string;
notes: string;
notes: string | null;
recipient: {
name: string;
tin: string;
tin: string | null;
format_address: string;
};

View File

@ -1,7 +1,12 @@
import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type { ProformaFullSnapshot, ProformaReportTaxSnapshot } from "../../application-models";
import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
type ProformaTaxReportSnapshotBuilderParams = ISnapshotBuilderParams & {
locale: string;
};
export interface IProformaTaxReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot["taxes"], ProformaReportTaxSnapshot[]> {}
@ -9,9 +14,9 @@ export interface IProformaTaxReportSnapshotBuilder
export class ProformaTaxReportSnapshotBuilder implements IProformaTaxReportSnapshotBuilder {
toOutput(
taxes: ProformaFullSnapshot["taxes"],
params?: ISnapshotBuilderParams
params?: ProformaTaxReportSnapshotBuilderParams
): ProformaReportTaxSnapshot[] {
const locale = params?.locale as string;
const locale = params?.locale ?? "es-ES";
const moneyOptions = {
hideZeros: true,
@ -21,16 +26,20 @@ export class ProformaTaxReportSnapshotBuilder implements IProformaTaxReportSnaps
return taxes.map((tax) => ({
taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions),
iva_code: tax.iva_code,
iva_percentage: PercentageDTOHelper.format(tax.iva_percentage, locale),
iva_code: tax.iva_code ?? "",
iva_percentage: tax.iva_percentage
? PercentageDTOHelper.format(tax.iva_percentage, locale)
: "",
iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions),
rec_code: tax.rec_code,
rec_percentage: PercentageDTOHelper.format(tax.rec_percentage, locale),
rec_code: tax.rec_code ?? "",
rec_percentage: tax.rec_percentage ? PercentageDTOHelper.format(tax.rec_percentage, locale) : "",
rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions),
retention_code: tax.retention_code,
retention_percentage: PercentageDTOHelper.format(tax.retention_percentage, locale),
retention_code: tax.retention_code ?? "",
retention_percentage: tax.retention_percentage
? PercentageDTOHelper.format(tax.retention_percentage, locale)
: "",
retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(tax.taxes_amount, locale, moneyOptions),

View File

@ -4,5 +4,6 @@ export * from "./create-proforma.use-case";
export * from "./get-proforma-by-id.use-case";
export * from "./issue-proforma.use-case";
export * from "./list-proformas.use-case";
export * from "./report-proforma.use-case";
export * from "./preview-proforma-report.use-case";
export * from "./report-proforma-pdf.use-case";
export * from "./update-proforma-by-id.use-case";

View File

@ -0,0 +1,124 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyReportProfileNotFoundError } from "../errors";
import type {
ICompanyReportProfileFinder,
IProformaFinder,
IProformaFullReadModelAssembler,
ProformaDocumentGeneratorService,
} from "../services";
import type { ReportLocalization } from "../models";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type PreviewProformaReportUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
export class PreviewProformaReportUseCase {
public constructor(
private readonly deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}
) {}
public execute(params: PreviewProformaReportUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.deps.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
proforma: proformaResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const companyProfileResult = await this.deps.companyReportProfileFinder.findByCompanyId(
companyId,
transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
}
if (companyProfileResult.data.isNone()) {
return Result.fail(new CompanyReportProfileNotFoundError(companyId.toString()));
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
const localization = this.resolveLocalization({
languageCode: fullSnapshot.language_code,
currencyCode: fullSnapshot.currency_code,
companyLocale: companyProfileResult.data.unwrap().locale,
companyCurrencyCode: companyProfileResult.data.unwrap().currencyCode,
});
const reportSnapshot = this.deps.reportSnapshotBuilder.toOutput(fullSnapshot, {
localization,
company: companyProfileResult.data.unwrap(),
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug,
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
return Result.ok({
payload: documentResult.data.payload.toString("utf-8"),
mimeType: "text/html; charset=utf-8" as const,
filename: documentResult.data.filename,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private resolveLocalization(params: {
languageCode: string;
currencyCode: string;
companyLocale: Maybe<string>;
companyCurrencyCode: Maybe<string>;
}): ReportLocalization {
return {
languageCode: params.languageCode || "es",
locale: params.companyLocale.getOrUndefined() ?? "es-ES",
currencyCode: params.currencyCode || params.companyCurrencyCode.getOrUndefined() || "EUR",
};
}
}

View File

@ -0,0 +1,124 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyReportProfileNotFoundError } from "../errors";
import type {
ICompanyReportProfileFinder,
IProformaFinder,
IProformaFullReadModelAssembler,
ProformaDocumentGeneratorService,
} from "../services";
import type { ReportLocalization } from "../models";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type ReportProformaPdfUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
export class ReportProformaPdfUseCase {
public constructor(
private readonly deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}
) {}
public execute(params: ReportProformaPdfUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.deps.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
proforma: proformaResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const companyProfileResult = await this.deps.companyReportProfileFinder.findByCompanyId(
companyId,
transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
}
if (companyProfileResult.data.isNone()) {
return Result.fail(new CompanyReportProfileNotFoundError(companyId.toString()));
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
const localization = this.resolveLocalization({
languageCode: fullSnapshot.language_code,
currencyCode: fullSnapshot.currency_code,
companyLocale: companyProfileResult.data.unwrap().locale,
companyCurrencyCode: companyProfileResult.data.unwrap().currencyCode,
});
const reportSnapshot = this.deps.reportSnapshotBuilder.toOutput(fullSnapshot, {
localization,
company: companyProfileResult.data.unwrap(),
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug,
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
return Result.ok({
payload: documentResult.data.payload,
mimeType: "application/pdf" as const,
filename: documentResult.data.filename,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private resolveLocalization(params: {
languageCode: string;
currencyCode: string;
companyLocale: Maybe<string>;
companyCurrencyCode: Maybe<string>;
}): ReportLocalization {
return {
languageCode: params.languageCode || "es",
locale: params.companyLocale.getOrUndefined() ?? "es-ES",
currencyCode: params.currencyCode || params.companyCurrencyCode.getOrUndefined() || "EUR",
};
}
}

View File

@ -1,80 +0,0 @@
import { type ITransactionManager, type RendererFormat, logger } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IProformaFinder, ProformaDocumentGeneratorService } from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type ReportProformaUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
format: RendererFormat;
};
export class ReportProformaUseCase {
constructor(
private readonly finder: IProformaFinder,
private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder,
private readonly reportSnapshotBuilder: IProformaReportSnapshotBuilder,
private readonly documentGenerationService: ProformaDocumentGeneratorService,
private readonly transactionManager: ITransactionManager
) {}
public async execute(params: ReportProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const invoice = proformaResult.data;
// Snapshot completo de la entidad
const fullSnapshot = this.fullSnapshotBuilder.toOutput(invoice);
// Snapshot para informe a partir del anterior
const reportSnapshot = this.reportSnapshotBuilder.toOutput(fullSnapshot, {
languageCode: invoice.languageCode,
});
// Llamar al servicio y que se apañe
const documentResult = await this.documentGenerationService.generate(reportSnapshot, {
companySlug: "rodax",
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
11;
// 5. Devolver artefacto firmado
return Result.ok({
payload: documentResult.data.payload,
mimeType: "application/pdf",
filename: documentResult.data.filename,
});
} catch (error: unknown) {
const err = error as Error;
logger.error(err.message, { label: "ReportProformaUseCase.execure" });
return Result.fail(err);
}
});
}
}

View File

@ -1,2 +0,0 @@
export * from "./report-proforma.use-case";
export * from "./reporter";

View File

@ -1,67 +0,0 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
type ReportProformaUseCaseInput = {
companyId: UniqueID;
companySlug: string;
proforma_id: string;
format: "pdf" | "html";
};
export class ReportProformaUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public async execute(params: ReportProformaUseCaseInput) {
const { proforma_id, companySlug, companyId, format } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
const reportPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format,
});
return this.transactionManager.complete(async (transaction) => {
try {
const proformaOrError = await this.service.getProformaByIdInCompany(
companyId,
proformaId,
transaction
);
if (proformaOrError.isFailure) {
return Result.fail(proformaOrError.error);
}
const proforma = proformaOrError.data;
const reportData = await reportPresenter.toOutput(proforma, { companySlug });
if (format === "html") {
return Result.ok({
data: String(reportData),
filename: undefined,
});
}
return Result.ok({
data: reportData as Buffer<ArrayBuffer>,
filename: `proforma-${proforma.invoiceNumber}.pdf`,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -1,32 +0,0 @@
import { TemplatePresenter } from "@erp/core/api";
import type { Proforma } from "../../../../../domain";
import type { ProformaFullPresenter, ProformaReportPresenter } from "../../../../snapshot-builders";
export class ProformaReportHTMLPresenter extends TemplatePresenter {
toOutput(proforma: Proforma, params: { companySlug: string }): string {
const { companySlug } = params;
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "FULL",
}) as ProformaFullPresenter;
const prePresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "JSON",
}) as ProformaReportPresenter;
const invoiceDTO = dtoPresenter.toOutput(proforma);
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const template = this.templateResolver.compileTemplate(
"customer-invoices",
companySlug,
"proforma.hbs"
);
return template(prettyDTO);
}
}

View File

@ -1,76 +0,0 @@
import { Presenter } from "@erp/core/api";
import puppeteer from "puppeteer";
import type { Proforma } from "../../../../../domain";
import type { ProformaReportHTMLPresenter } from "./proforma.report.html";
// https://plnkr.co/edit/lWk6Yd?preview
// https://latenode.com/es/blog/web-automation-scraping/puppeteer-fundamentals-setup/complete-guide-to-pdf-generation-with-puppeteer-from-simple-documents-to-complex-reports
export class ProformaReportPDFPresenter extends Presenter<Proforma, Promise<Buffer<ArrayBuffer>>> {
async toOutput(
proforma: Proforma,
params: { companySlug: string }
): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "HTML",
}) as ProformaReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(proforma, params);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--font-render-hinting=medium",
],
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await page.setContent(htmlData, {
waitUntil: "networkidle2",
});
// Espera extra opcional si hay imágenes base64 muy grandes
await page.waitForNetworkIdle({ idleTime: 200, timeout: 5000 });
const reportPDF = await page.pdf({
format: "A4",
margin: {
bottom: 0,
left: 0,
right: 0,
top: 0,
},
landscape: false,
preferCSSPageSize: true,
omitBackground: false,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></span></div>',
});
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);
throw err as Error;
}
}
}

View File

@ -0,0 +1,51 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result, type Result as ResultType } from "@repo/rdx-utils";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import type { CompanyReportProfile, ICompanyReportProfileFinder } from "../../../application";
export class CompanyReportProfileFinder implements ICompanyReportProfileFinder {
public constructor(
private readonly deps: {
companiesServices: ICompanyPublicServices;
}
) {}
public async findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<ResultType<Maybe<CompanyReportProfile>, Error>> {
const result = await this.deps.companiesServices.finder.findById({
companyId,
transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(Maybe.none());
}
const company = result.data.unwrap();
const profile: CompanyReportProfile = {
companyId: company.id,
slug: company.slug,
legalName: company.legalName,
tradeName: Maybe.fromNullable(company.tradeName ?? undefined),
tin: Maybe.fromNullable(company.tin ?? undefined),
email: Maybe.fromNullable(company.email ?? undefined),
phone: Maybe.fromNullable(company.phone ?? undefined),
website: Maybe.fromNullable(company.website ?? undefined),
locale: Maybe.none(),
currencyCode: Maybe.none(),
logo: Maybe.none(),
address: Maybe.none(),
version: Maybe.none(),
};
return Result.ok(Maybe.some(profile));
}
}

View File

@ -1,27 +1,28 @@
import { type ModuleParams, buildCoreDocumentsDI } from "@erp/core/api";
import {
ProformaDocumentPipelineFactory,
type ProformaDocumentPipelineFactoryDeps,
ProformaHtmlPreviewPipelineFactory,
type ProformaHtmlPreviewPipelineFactoryDeps,
ProformaPdfDocumentPipelineFactory,
type ProformaPdfDocumentPipelineFactoryDeps,
} from "../documents";
export const buildProformaDocumentService = (params: ModuleParams) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(params);
export const buildProformaDocumentServices = (params: ModuleParams) => {
const { documentRenderers, documentStorage } = buildCoreDocumentsDI(params);
const pipelineDeps: ProformaDocumentPipelineFactoryDeps = {
const pdfPipelineDeps: ProformaPdfDocumentPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer,
//
signingContextResolver: documentSigning.signingContextResolver,
documentSigningService: documentSigning.signingService,
//
documentStorage: documentStorage.storage,
templateResolver: documentRenderers.fastReportTemplateResolver,
};
const documentGeneratorPipeline = ProformaDocumentPipelineFactory.create(pipelineDeps);
const htmlPreviewPipelineDeps: ProformaHtmlPreviewPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer,
templateResolver: documentRenderers.fastReportTemplateResolver,
};
return documentGeneratorPipeline;
return {
pdf: ProformaPdfDocumentPipelineFactory.create(pdfPipelineDeps),
htmlPreview: ProformaHtmlPreviewPipelineFactory.create(htmlPreviewPipelineDeps),
};
};

View File

@ -65,6 +65,7 @@ export function buildProformaPublicServices(
taxDefinitionFinder: catalogs.taxDefinition.finder,
taxRegimeFinder: catalogs.taxRegime.finder,
paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
});
const readModelAssemblers = buildProformaReadModelAssemblers({

View File

@ -1,4 +1,5 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import {
type ChangeStatusProformaUseCase,
@ -7,13 +8,15 @@ import {
type IIssuedInvoicePublicServices,
type IssueProformaUseCase,
type ListProformasUseCase,
type ReportProformaUseCase,
type PreviewProformaReportUseCase,
type ReportProformaPdfUseCase,
type UpdateProformaByIdUseCase,
buildChangeStatusProformaUseCase,
buildCreateProformaUseCase,
buildGetProformaByIdUseCase,
buildIssueProformaUseCase,
buildListProformasUseCase,
buildPreviewProformaReportUseCase,
buildProformaCatalogResolvers,
buildProformaCreator,
buildProformaFinder,
@ -25,11 +28,12 @@ import {
buildProformaStatusChanger,
buildProformaToIssuedInvoicePropsConverter,
buildProformaUpdater,
buildReportProformaUseCase,
buildReportProformaPdfUseCase,
buildUpdateProformaUseCase,
} from "../../../application";
import { buildProformaDocumentService } from "./proforma-documents.di";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { buildProformaDocumentServices } from "./proforma-documents.di";
import { buildProformaNumberGenerator } from "./proforma-number-generator.di";
import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
import { buildProformaRepository } from "./proforma-repositories.di";
@ -39,7 +43,8 @@ export type ProformasInternalDeps = {
useCases: {
listProformas: () => ListProformasUseCase;
getProformaById: () => GetProformaByIdUseCase;
reportProforma: () => ReportProformaUseCase;
reportProformaPdf: () => ReportProformaPdfUseCase;
previewProformaReport: () => PreviewProformaReportUseCase;
createProforma: () => CreateProformaUseCase;
issueProforma: (publicServices: {
issuedInvoiceServices: IIssuedInvoicePublicServices;
@ -58,6 +63,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const { database } = params;
const catalogs = resolveProformaCatalogsDeps(params);
const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
const transactionManager = buildTransactionManager(database);
const persistenceMappers = buildProformaPersistenceMappers();
@ -113,7 +119,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
paymentMethodFinder: catalogs.paymentMethod.finder,
});
const documentGeneratorPipeline = buildProformaDocumentService(params);
const companyReportProfileFinder = new CompanyReportProfileFinder({
companiesServices,
});
const documentGeneratorServices = buildProformaDocumentServices(params);
return {
useCases: {
@ -150,13 +159,25 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager,
}),
reportProforma: () =>
buildReportProformaUseCase({
reportProformaPdf: () =>
buildReportProformaPdfUseCase({
finder,
companyReportProfileFinder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: documentGeneratorPipeline,
documentService: documentGeneratorServices.pdf,
transactionManager,
}),
previewProformaReport: () =>
buildPreviewProformaReportUseCase({
finder,
companyReportProfileFinder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: documentGeneratorServices.htmlPreview,
transactionManager,
}),

View File

@ -1 +1,2 @@
export * from "./proforma-document-pipeline-factory.ts";
export * from "./proforma-html-preview-pipeline-factory.ts";
export * from "./proforma-pdf-document-pipeline-factory.ts";

View File

@ -1,71 +0,0 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentPostProcessor,
type IDocumentSideEffect,
type IDocumentSigningService,
type IDocumentStorage,
type ISigningContextResolver,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentMetadataFactory,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaSignedDocumentCachePreProcessor } from "../pre-processors";
import { ProformaDocumentRenderer } from "../renderers";
import { PersistProformaDocumentSideEffect } from "../side-effects";
/**
* Factory de pipeline de generación de documentos
* para facturas emitidas (Issued Invoice).
*/
export interface ProformaDocumentPipelineFactoryDeps {
// Core / Infra
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
signingContextResolver: ISigningContextResolver;
documentSigningService: IDocumentSigningService;
documentStorage: IDocumentStorage;
}
export class ProformaDocumentPipelineFactory {
static create(deps: ProformaDocumentPipelineFactoryDeps): ProformaDocumentGeneratorService {
// 1. Pre-processors (cache firmado)
const preProcessors = [new ProformaSignedDocumentCachePreProcessor(deps.documentStorage)];
// 2. Renderer (FastReport)
const renderer = new ProformaDocumentRenderer(deps.fastReportRenderer, deps.templateResolver);
// 3) Metadata and properties factory (Application)
const metadataFactory = new ProformaDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
// 3) Firma real (Core / Infra)
const postProcessor: IDocumentPostProcessor = new DocumentPostProcessorChain([
// Aquí podrían ir más post-procesadores, como uno de validación o similar
]);
// 4. Side-effects (persistencia best-effort)
const sideEffects: IDocumentSideEffect[] = [
new PersistProformaDocumentSideEffect(deps.documentStorage),
];
// 5. Pipeline final
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors,
postProcessor,
sideEffects,
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -0,0 +1,40 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaHtmlPreviewRenderer } from "../renderers";
import { ProformaHtmlPreviewDocumentMetadataFactory } from "../services/proforma-html-preview-document-metadata-factory";
export interface ProformaHtmlPreviewPipelineFactoryDeps {
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
}
export class ProformaHtmlPreviewPipelineFactory {
static create(deps: ProformaHtmlPreviewPipelineFactoryDeps): ProformaDocumentGeneratorService {
const renderer = new ProformaHtmlPreviewRenderer(
deps.fastReportRenderer,
deps.templateResolver
);
const metadataFactory = new ProformaHtmlPreviewDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
const postProcessor = new DocumentPostProcessorChain([]);
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors: [],
postProcessor,
sideEffects: [],
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -0,0 +1,49 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentSideEffect,
type IDocumentStorage,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaPdfCachePreProcessor } from "../pre-processors";
import { ProformaPdfDocumentRenderer } from "../renderers";
import { ProformaPdfDocumentMetadataFactory } from "../services/proforma-pdf-document-metadata-factory";
import { PersistProformaPdfSideEffect } from "../side-effects";
export interface ProformaPdfDocumentPipelineFactoryDeps {
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
documentStorage: IDocumentStorage;
}
export class ProformaPdfDocumentPipelineFactory {
static create(deps: ProformaPdfDocumentPipelineFactoryDeps): ProformaDocumentGeneratorService {
const preProcessors = [new ProformaPdfCachePreProcessor(deps.documentStorage)];
const renderer = new ProformaPdfDocumentRenderer(
deps.fastReportRenderer,
deps.templateResolver
);
const metadataFactory = new ProformaPdfDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
const postProcessor = new DocumentPostProcessorChain([]);
const sideEffects: IDocumentSideEffect[] = [
new PersistProformaPdfSideEffect(deps.documentStorage),
];
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors,
postProcessor,
sideEffects,
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -1 +1 @@
export * from "./proforma-document-cache-pre-processor";
export * from "./proforma-pdf-cache-pre-processor";

View File

@ -1,71 +0,0 @@
import {
DocumentStorageKeyFactory,
type IDocument,
type IDocumentMetadata,
type IDocumentPreProcessor,
type IDocumentStorage,
logger,
} from "@erp/core/api";
/**
* Pre-processor de cache técnico para documentos firmados
* de facturas emitidas.
*
* - Best-effort
* - Nunca rompe el flujo
* - Invalida cache corrupto
*/
export class ProformaSignedDocumentCachePreProcessor implements IDocumentPreProcessor {
constructor(private readonly docStorage: IDocumentStorage) {}
async tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null> {
const metadataRecord = metadata as unknown as Record<string, unknown>;
try {
const { paths, storageKey } = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
if (!storageKey) {
return null;
}
const exists = await this.docStorage.existsKeyStorage(storageKey, paths);
if (!exists) {
return null;
}
logger.info(`✅ Found Server cached document for key ${storageKey}`);
const document = await this.docStorage.readDocument(storageKey, paths);
if (!this.isValid(document)) {
logger.warn(`Corrupted or invalid cached document for key ${storageKey}`, {
label: "ProformaSignedDocumentCachePreProcessor",
});
return null;
}
return document;
} catch {
// best-effort: cualquier fallo se trata como cache miss
return null;
}
}
/**
* Validación mínima de integridad.
* No valida firma criptográfica.
*/
private isValid(document: IDocument | null): boolean {
if (!document) return false;
if (!document.payload || document.payload.length === 0) {
return false;
}
if (document.mimeType !== "application/pdf") {
return false;
}
return true;
}
}

View File

@ -0,0 +1,34 @@
import {
DocumentStorageKeyFactory,
type IDocument,
type IDocumentMetadata,
type IDocumentPreProcessor,
type IDocumentStorage,
} from "@erp/core/api";
export class ProformaPdfCachePreProcessor implements IDocumentPreProcessor {
public constructor(private readonly docStorage: IDocumentStorage) {}
public async tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null> {
const metadataRecord = metadata as unknown as Record<string, unknown>;
const { paths, storageKey } = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
if (!storageKey) {
return null;
}
const exists = await this.docStorage.existsKeyStorage(storageKey, paths);
if (!exists) {
return null;
}
const document = await this.docStorage.readDocument(storageKey, paths);
if (!document || document.mimeType !== "application/pdf" || document.payload.length === 0) {
return null;
}
return document;
}
}

View File

@ -1 +1,2 @@
export * from "./proforma-document-renderer.ts";
export * from "./proforma-html-preview-renderer.ts";
export * from "./proforma-pdf-document-renderer.ts";

View File

@ -1,72 +0,0 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
/**
* Adaptador Application Infra para la generación del documento
* PDF de una factura emitida (Issued Invoice).
*
* - Recibe snapshot de report
* - Invoca FastReportRenderer
* - Devuelve IDocument
*
* NO captura errores: FastReportError se propaga.
*/
export type ProformaDocumentRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaDocumentRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
async render(
snapshot: ProformaReportSnapshot,
params: ProformaDocumentRenderParams
): Promise<IDocument> {
const { companySlug, format, languageCode, filename, mimeType, properties } = params;
// Template
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug,
languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format,
properties,
});
return {
payload: this.normalizePayload(output.payload),
mimeType,
filename,
};
}
/**
* Normaliza la salida de FastReport a Buffer.
* FastReport puede devolver string o Buffer.
*/
private normalizePayload(payload: Buffer | string): Buffer {
return Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
}
}

View File

@ -0,0 +1,50 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
type ProformaHtmlPreviewRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaHtmlPreviewRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
public async render(
snapshot: ProformaReportSnapshot,
params: ProformaHtmlPreviewRenderParams
): Promise<IDocument> {
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug: params.companySlug,
languageCode: params.languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format: params.format,
properties: params.properties,
});
return {
payload: Buffer.isBuffer(output.payload) ? output.payload : Buffer.from(output.payload),
mimeType: params.mimeType,
filename: params.filename,
};
}
}

View File

@ -0,0 +1,50 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
type ProformaPdfDocumentRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaPdfDocumentRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
public async render(
snapshot: ProformaReportSnapshot,
params: ProformaPdfDocumentRenderParams
): Promise<IDocument> {
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug: params.companySlug,
languageCode: params.languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format: params.format,
properties: params.properties,
});
return {
payload: Buffer.isBuffer(output.payload) ? output.payload : Buffer.from(output.payload),
mimeType: params.mimeType,
filename: params.filename,
};
}
}

View File

@ -0,0 +1,19 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../application";
export class ProformaHtmlPreviewDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company.slug,
format: "HTML",
languageCode: snapshot.localization.languageCode,
filename: `proforma-${snapshot.series}${snapshot.invoice_number}.html`,
};
}
}

View File

@ -0,0 +1,48 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../application";
export class ProformaPdfDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company.slug,
format: "PDF",
languageCode: snapshot.localization.languageCode,
filename: `proforma-${snapshot.series}${snapshot.invoice_number}.pdf`,
storageKey: this.buildStorageKey(snapshot),
};
}
private buildStorageKey(snapshot: ProformaReportSnapshot): string | undefined {
if (snapshot.status === "draft") {
return undefined;
}
// La cache de proformas no draft es segura respecto al contenido de la proforma
// porque el dominio no permite modificar proformas fuera de draft. Queda pendiente
// invalidar por version de plantilla/branding cuando esos datos existan.
const keyParts = [
"proforma",
snapshot.company_id,
snapshot.id,
snapshot.status,
"pdf",
snapshot.localization.languageCode,
snapshot.localization.currencyCode,
snapshot.company.slug,
];
const companyVersion = snapshot.company.version.getOrUndefined();
if (companyVersion) {
keyParts.push(companyVersion);
}
return keyParts.join(":");
}
}

View File

@ -1 +1 @@
export * from "./persist-proforma-document-side-effect";
export * from "./persist-proforma-pdf-side-effect";

View File

@ -1,27 +0,0 @@
import type {
IDocument,
IDocumentMetadata,
IDocumentSideEffect,
IDocumentStorage,
} from "@erp/core/api";
/**
* Side-effect de persistencia best-effort del documento final
* de una factura emitida.
*
* - Nunca rompe el flujo
* - Usa cacheKey/metadata para decidir la clave
*/
export class PersistProformaDocumentSideEffect implements IDocumentSideEffect {
constructor(private readonly storage: IDocumentStorage) {}
async execute(document: IDocument, metadata: IDocumentMetadata): Promise<void> {
// Si no hay cacheKey, no se persiste
if (!metadata.storageKey) {
return;
}
// Persistencia best-effort
await this.storage.saveDocument(document, metadata as unknown as Record<string, unknown>);
}
}

View File

@ -0,0 +1,18 @@
import type {
IDocument,
IDocumentMetadata,
IDocumentSideEffect,
IDocumentStorage,
} from "@erp/core/api";
export class PersistProformaPdfSideEffect implements IDocumentSideEffect {
public constructor(private readonly storage: IDocumentStorage) {}
public async execute(document: IDocument, metadata: IDocumentMetadata): Promise<void> {
if (!metadata.storageKey) {
return;
}
await this.storage.saveDocument(document, metadata as unknown as Record<string, unknown>);
}
}

View File

@ -4,5 +4,6 @@
export * from "./get-proforma.controller";
export * from "./issue-proforma.controller";
export * from "./list-proformas.controller";
export * from "./report-proforma.controller";
export * from "./preview-proforma-report.controller";
export * from "./report-proforma-pdf.controller";
export * from "./update-proforma.controller";

View File

@ -0,0 +1,41 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { PreviewProformaReportUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class PreviewProformaReportController extends ExpressController {
public constructor(private readonly useCase: PreviewProformaReportUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({
proforma_id,
companyId,
});
return result.match(
({ payload }) => this.downloadHTML(payload),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,41 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { ReportProformaPdfUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ReportProformaPdfController extends ExpressController {
public constructor(private readonly useCase: ReportProformaPdfUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({
proforma_id,
companyId,
});
return result.match(
({ payload, filename }) => this.downloadPDF(payload, String(filename)),
(err) => this.handleError(err)
);
}
}

View File

@ -1,55 +0,0 @@
import {
ExpressController,
type RendererFormat,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { ReportProformaByIdQueryRequestDTO } from "../../../../../common";
import type { ReportProformaUseCase } from "../../../../application/index.ts";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ReportProformaController extends ExpressController {
public constructor(private readonly useCase: ReportProformaUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const { format } = this.req.query as ReportProformaByIdQueryRequestDTO;
const result = await this.useCase.execute({
proforma_id,
companyId,
format: format as RendererFormat,
});
return result.match(
({ payload, filename }) => {
if (format === "PDF") {
return this.downloadPDF(payload as Buffer<ArrayBuffer>, String(filename));
}
if (format === "HTML") {
return this.downloadHTML(payload as unknown as string);
}
// JSON
return this.json(payload);
},
(err) => this.handleError(err)
);
}
}

View File

@ -5,6 +5,7 @@ import {
ValidationApiError,
} from "@erp/core/api";
import { CompanyReportProfileNotFoundError, isCompanyReportProfileNotFoundError } from "../../../application";
import {
type CustomerInvoiceIdAlreadyExistsError,
type EntityIsNotProformaError,
@ -77,8 +78,19 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = {
),
};
const companyReportProfileNotFoundRule: ErrorToApiRule = {
priority: 130,
matches: (e) => isCompanyReportProfileNotFoundError(e),
build: (e) =>
new ConflictApiError(
(e as CompanyReportProfileNotFoundError).message ||
"Cannot generate the proforma report because the company document profile is missing."
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(companyReportProfileNotFoundRule)
.register(proformaDuplicateRule)
.register(proformaItemMismatchError)
.register(entityIsNotProformaError)

View File

@ -6,8 +6,9 @@ import {
GetProformaController,
IssueProformaController,
ListProformasController,
PreviewProformaReportController,
type ProformasInternalDeps,
ReportProformaController,
ReportProformaPdfController,
} from "..";
import {
@ -17,8 +18,8 @@ import {
GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema,
ReportProformaByIdParamsRequestSchema,
ReportProformaByIdQueryRequestSchema,
PreviewProformaReportByIdParamsRequestSchema,
ReportProformaPdfByIdParamsRequestSchema,
UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema,
} from "../../../../common";
@ -70,14 +71,21 @@ export const proformasRouter = (params: StartParams) => {
);
router.get(
"/:proforma_id/report",
//checkTabContext,
validateRequest(ReportProformaByIdParamsRequestSchema, "params"),
validateRequest(ReportProformaByIdQueryRequestSchema, "query"),
"/:proforma_id/report/pdf",
validateRequest(ReportProformaPdfByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.reportProforma();
const controller = new ReportProformaController(useCase);
const useCase = deps.useCases.reportProformaPdf();
const controller = new ReportProformaPdfController(useCase);
return controller.execute(req, res, next);
}
);
router.get(
"/:proforma_id/report/preview",
validateRequest(PreviewProformaReportByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.previewProformaReport();
const controller = new PreviewProformaReportController(useCase);
return controller.execute(req, res, next);
}
);

View File

@ -4,5 +4,6 @@ export * from "./delete-proforma-by-id.request.dto";
export * from "./get-proforma-by-id.request.dto";
export * from "./issue-proforma-by-id.request.dto";
export * from "./list-proformas.request.dto";
export * from "./report-proforma-by-id.request.dto";
export * from "./preview-proforma-report-by-id.request.dto";
export * from "./report-proforma-pdf-by-id.request.dto";
export * from "./update-proforma-by-id.request.dto";

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const PreviewProformaReportByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type PreviewProformaReportByIdParamsRequestDTO = z.infer<
typeof PreviewProformaReportByIdParamsRequestSchema
>;

View File

@ -1,21 +0,0 @@
import { z } from "zod/v4";
export const ReportProformaByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type ReportProformaByIdParamsRequestDTO = z.infer<
typeof ReportProformaByIdParamsRequestSchema
>;
export const ReportProformaByIdQueryRequestSchema = z.object({
format: z
.string()
.default("pdf")
.transform((v) => v.trim().toLowerCase())
.pipe(z.enum(["pdf", "html", "json"]))
.transform((v) => v.toUpperCase() as "PDF" | "HTML" | "JSON"),
});
export type ReportProformaByIdQueryRequestDTO = z.infer<
typeof ReportProformaByIdQueryRequestSchema
>;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const ReportProformaPdfByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type ReportProformaPdfByIdParamsRequestDTO = z.infer<
typeof ReportProformaPdfByIdParamsRequestSchema
>;

View File

@ -2,5 +2,4 @@ export * from "./create-proforma.response.dto";
export * from "./get-proforma-by-id.response.dto";
export * from "./issue-proforma-by-id.response.dto";
export * from "./list-proformas.response.dto";
export * from "./report-proforma-by-id.response.dto";
export * from "./update-proformas-by-id.response.dto";

View File

@ -1 +0,0 @@
export type ReportProformaByIdResponseDTO = Blob;

View File

@ -2,7 +2,9 @@ import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils";
import {
Button,
ButtonGroup,
Checkbox,
Spinner,
Tooltip,
TooltipContent,
TooltipProvider,
@ -11,6 +13,7 @@ import {
import type { ColumnDef } from "@tanstack/react-table";
import {
ExternalLinkIcon,
FileDownIcon,
FileTextIcon,
PencilIcon,
RefreshCwIcon,
@ -39,6 +42,10 @@ type GridActionHandlers = {
canIssue?: (proforma: ProformaListRow) => boolean;
canEdit?: (proforma: ProformaListRow) => boolean;
canDelete?: (proforma: ProformaListRow) => boolean;
onDownloadPdf?: (proforma: ProformaListRow) => void;
pdfDownloadingId?: string | null;
isPdfDownloading?: boolean;
};
export function useProformasGridColumns(
@ -269,8 +276,12 @@ export function useProformasGridColumns(
const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<div className="flex items-center gap-1 justify-end">
<ButtonGroup>
{!isIssued && actionHandlers.onEditClick && (
<TooltipProvider>
<Tooltip>
@ -352,6 +363,34 @@ export function useProformasGridColumns(
</TooltipProvider>
)}
{/* Descargar en PDF */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className={"size-8"}
disabled={isPDFLoading}
onClick={(e) => {
stop(e);
actionHandlers.onDownloadPdf?.(proforma);
}}
size="icon"
variant="ghost"
>
{isPDFLoading ? (
<Spinner className="size-4 cursor-progress" />
) : (
<FileDownIcon className="size-4 cursor-pointer" />
)}
<span className="sr-only">Descargar PDF</span>
</Button>
}
/>
<TooltipContent>Descargar PDF</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Eliminar */}
{!isIssued && actionHandlers.onDeleteClick && (
<TooltipProvider>
@ -377,7 +416,7 @@ export function useProformasGridColumns(
</Tooltip>
</TooltipProvider>
)}
</div>
</ButtonGroup>
);
},
},

View File

@ -64,6 +64,8 @@ export const ListProformasPage = () => {
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)),
onChangeStatusClick: (proforma) =>
changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)),
onDownloadPdf: (proforma) => null,
});
const isPanelOpen = panelCtrl.panelState.isOpen;

View File

@ -1,35 +1,22 @@
import type { IDataSource } from "@erp/core/client";
import type {
ReportProformaByIdQueryRequestDTO,
ReportProformaByIdResponseDTO,
} from "../../../../common";
export type ProformaReportFormat = "PDF" | "HTML" | "JSON";
export interface ReportProformaByIdParams {
id: string;
data: ReportProformaByIdQueryRequestDTO;
}
export type ReportProformaByIdResult = ReportProformaByIdResponseDTO;
export type ReportProformaByIdResult = Blob;
export const reportProformaById = (
dataSource: IDataSource,
params: ReportProformaByIdParams
): Promise<ReportProformaByIdResult> => {
const {
id,
data: { format = "PDF" },
} = params;
const { id } = params;
if (!id) throw new Error("proformaId is required");
return dataSource.custom<ReportProformaByIdQueryRequestDTO, ReportProformaByIdResponseDTO>({
path: `proformas/${id}/report`,
return dataSource.custom<undefined, Blob>({
path: `proformas/${id}/report/pdf`,
method: "get",
data: {
format,
},
data: undefined,
});
};