This commit is contained in:
David Arranz 2026-02-07 23:07:23 +01:00
parent 86253f5dcd
commit dc204237b1
237 changed files with 3479 additions and 967 deletions

View File

@ -1,8 +1,9 @@
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
//import verifactuAPIModule from "@erp/verifactu/api";
import customersAPIModule from "@erp/customers/api";
import { registerModule } from "./lib";
export const registerModules = () => {

View File

@ -66,7 +66,9 @@
"options": {
"strictCase": false,
"requireAscii": true,
"filenameCases": ["kebab-case"]
"filenameCases": [
"kebab-case"
]
}
},
"useForOf": "error",
@ -81,19 +83,28 @@
"selector": {
"kind": "function"
},
"formats": ["camelCase", "PascalCase"]
"formats": [
"camelCase",
"PascalCase"
]
},
{
"selector": {
"kind": "variable"
},
"formats": ["camelCase", "PascalCase", "CONSTANT_CASE"]
"formats": [
"camelCase",
"PascalCase",
"CONSTANT_CASE"
]
},
{
"selector": {
"kind": "typeLike"
},
"formats": ["PascalCase"]
"formats": [
"PascalCase"
]
}
]
}
@ -169,7 +180,7 @@
}
},
"noForEach": "warn",
"noStaticOnlyClass": "error",
"noStaticOnlyClass": "off",
"noThisInStatic": "error",
"noUselessCatch": "error",
"noUselessConstructor": "error",
@ -182,7 +193,7 @@
"noVoid": "error",
"useFlatMap": "error",
"useLiteralKeys": "error",
"useOptionalChain": "error",
"useOptionalChain": "off",
"useSimpleNumberKeys": "error",
"useSimplifiedLogicExpression": "info"
},
@ -234,21 +245,46 @@
"groups": [
":URL:",
":BLANK_LINE:",
[":BUN:", ":NODE:"],
[
":BUN:",
":NODE:"
],
":BLANK_LINE:",
":PACKAGE_WITH_PROTOCOL:",
":BLANK_LINE:",
[":PACKAGE:"],
[
":PACKAGE:"
],
":BLANK_LINE:",
["!@/**", "!#*", "!~*", "!$*", "!%*"],
[
"!@/**",
"!#*",
"!~*",
"!$*",
"!%*"
],
":BLANK_LINE:",
["@/**", "#*", "~*", "$*", "%*"],
[
"@/**",
"#*",
"~*",
"$*",
"%*"
],
":BLANK_LINE:",
[":PATH:", "!./**", "!../**"],
[
":PATH:",
"!./**",
"!../**"
],
":BLANK_LINE:",
["../**"],
[
"../**"
],
":BLANK_LINE:",
["./**"]
[
"./**"
]
],
"identifierOrder": "lexicographic"
}
@ -268,7 +304,12 @@
"semicolons": "always",
"trailingCommas": "es5"
},
"globals": ["console", "process", "__dirname", "__filename"]
"globals": [
"console",
"process",
"__dirname",
"__filename"
]
},
"json": {
"formatter": {
@ -302,7 +343,11 @@
},
"overrides": [
{
"includes": ["**/*.test.{js,ts,tsx}", "**/*.spec.{js,ts,tsx}", "**/__tests__/**"],
"includes": [
"**/*.test.{js,ts,tsx}",
"**/*.spec.{js,ts,tsx}",
"**/__tests__/**"
],
"linter": {
"rules": {
"suspicious": {
@ -316,7 +361,11 @@
}
},
{
"includes": ["**/next.config.{js,ts}", "**/tailwind.config.{js,ts}", "**/*.config.{js,ts}"],
"includes": [
"**/next.config.{js,ts}",
"**/tailwind.config.{js,ts}",
"**/*.config.{js,ts}"
],
"linter": {
"rules": {
"style": {
@ -329,7 +378,11 @@
}
},
{
"includes": ["**/pages/**", "**/app/**/page.{tsx,jsx}", "**/app/**/layout.{tsx,jsx}"],
"includes": [
"**/pages/**",
"**/app/**/page.{tsx,jsx}",
"**/app/**/layout.{tsx,jsx}"
],
"linter": {
"rules": {
"style": {
@ -339,7 +392,10 @@
}
},
{
"includes": ["**/*.d.ts", "**/lib/env/*.ts"],
"includes": [
"**/*.d.ts",
"**/lib/env/*.ts"
],
"linter": {
"rules": {
"style": {
@ -353,7 +409,10 @@
"selector": {
"kind": "objectLiteralProperty"
},
"formats": ["CONSTANT_CASE", "camelCase"]
"formats": [
"CONSTANT_CASE",
"camelCase"
]
}
]
}

View File

@ -0,0 +1,18 @@
/**
* Contexto de certificados de una empresa para firma digital.
*
* - NO contiene certificados ni passwords
* - Contiene referencias a secretos externos (Infisical, etc.)
* - Application-level
*/
export interface ICompanyCertificateContext {
/** Nombre del secreto que contiene el certificado */
readonly certificateSecretName: string;
/** Nombre del secreto que contiene la password del certificado */
readonly certificatePasswordSecretName: string;
/** Identificador lógico de la empresa */
readonly companySlug: string;
}

View File

@ -0,0 +1,9 @@
export interface IDocumentMetadata {
readonly documentType: string;
readonly documentId: string;
readonly companyId: string;
readonly format: "PDF" | "HTML";
readonly languageCode: string;
readonly filename: string;
readonly cacheKey?: string; // opcional
}

View File

@ -0,0 +1,5 @@
export interface IDocument {
readonly payload: Buffer;
readonly mimeType: string;
readonly filename: string;
}

View File

@ -0,0 +1,3 @@
export * from "./company-certificate-context.interface";
export * from "./document.interface";
export * from "./document-metadata.interface";

View File

@ -0,0 +1,80 @@
import { ApplicationError } from "@repo/rdx-ddd";
type DocumentGenerationErrorCode =
| "METADATA_ERROR"
| "CACHE_ERROR" // no fatal (solo para métricas/logs si se usa)
| "RENDER_ERROR"
| "POST_PROCESS_ERROR"
| "SIDE_EFFECT_PROCESS_ERROR"
| "SIGNING_ERROR"
| "SECURITY_ERROR"
| "CONFIGURATION_ERROR"
| "INFRASTRUCTURE_ERROR";
export const isDocumentGenerationError = (e: unknown): e is DocumentGenerationError =>
e instanceof DocumentGenerationError;
export class DocumentGenerationError extends ApplicationError {
readonly kind = "document-generation";
private constructor(
message: string,
code: DocumentGenerationErrorCode,
cause?: unknown,
metadata?: Record<string, unknown>
) {
super(message, code, { cause, metadata });
}
static metadata(cause: unknown) {
return new DocumentGenerationError("Invalid document metadata", "METADATA_ERROR", cause);
}
static render(cause: unknown) {
return new DocumentGenerationError("Document render failed", "RENDER_ERROR", cause);
}
static postProcess(cause: unknown) {
return new DocumentGenerationError(
"Document post-processing failed",
"POST_PROCESS_ERROR",
cause
);
}
static sideEffect(cause: unknown) {
return new DocumentGenerationError(
"Document side effect failed",
"SIDE_EFFECT_PROCESS_ERROR",
cause
);
}
static signing(cause: unknown) {
return new DocumentGenerationError("Document signing failed", "SIGNING_ERROR", cause);
}
static security(cause: unknown) {
return new DocumentGenerationError(
"Security error during document generation",
"SECURITY_ERROR",
cause
);
}
static configuration(cause: unknown) {
return new DocumentGenerationError(
"Document generation misconfiguration",
"CONFIGURATION_ERROR",
cause
);
}
static infrastructure(cause: unknown) {
return new DocumentGenerationError(
"Infrastructure error during document generation",
"INFRASTRUCTURE_ERROR",
cause
);
}
}

View File

@ -0,0 +1 @@
export * from "./document-generation-error";

View File

@ -0,0 +1,2 @@
export * from "./application-models";
export * from "./services";

View File

@ -0,0 +1,16 @@
import type { IDocumentMetadata } from "../application-models";
export class DocumentCacheKeyFactory {
static fromMetadata(metadata: IDocumentMetadata): string {
return;
return [
metadata.documentType,
metadata.companyId,
metadata.documentId,
metadata.format,
metadata.languageCode,
metadata.filename,
metadata.cacheKey,
].join(":");
}
}

View File

@ -0,0 +1,17 @@
import type { IDocument } from "@erp/core/api/application";
export interface IDocumentCacheStore {
/**
* Devuelve el documento firmado si existe y es válido.
* - null => no existe / no válido
* - nunca lanza por errores técnicos (best-effort)
*/
get(cacheKey: string): Promise<IDocument | null>;
/**
* Guarda un documento firmado en cache.
* - best-effort
* - errores se ignoran o se loguean
*/
set(cacheKey: string, document: IDocument): Promise<void>;
}

View File

@ -0,0 +1,61 @@
import { ApplicationError } from "@repo/rdx-ddd";
export type DocumentGenerationErrorType =
| "METADATA"
| "PRE_PROCESS"
| "RENDER"
| "POST_PROCESS"
| "SIDE_EFFECT";
export class DocumentGenerationError extends ApplicationError {
public readonly documentErrorType: DocumentGenerationErrorType;
private constructor(
type: DocumentGenerationErrorType,
message: string,
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super(message, "DOCUMENT_GENERATION_ERROR", options);
this.documentErrorType = type;
}
// ---- factories (único punto de creación) ----
static metadata(error: unknown, metadata?: Record<string, unknown>): DocumentGenerationError {
return new DocumentGenerationError("METADATA", "Failed to build document metadata", {
cause: error,
metadata,
});
}
static preProcess(error: unknown, metadata?: Record<string, unknown>): DocumentGenerationError {
return new DocumentGenerationError("PRE_PROCESS", "Failed during document pre-processing", {
cause: error,
metadata,
});
}
static render(error: unknown, metadata?: Record<string, unknown>): DocumentGenerationError {
return new DocumentGenerationError("RENDER", "Failed to render document", {
cause: error,
metadata,
});
}
static postProcess(error: unknown, metadata?: Record<string, unknown>): DocumentGenerationError {
return new DocumentGenerationError("POST_PROCESS", "Failed during document post-processing", {
cause: error,
metadata,
});
}
static sideEffect(error: unknown, metadata?: Record<string, unknown>): DocumentGenerationError {
return new DocumentGenerationError("SIDE_EFFECT", "Failed during document side-effects", {
cause: error,
metadata,
});
}
}
export const isDocumentGenerationError = (e: unknown): e is DocumentGenerationError =>
e instanceof DocumentGenerationError;

View File

@ -0,0 +1,91 @@
import { logger } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
import type { IDocument, IDocumentMetadata } from "../application-models";
import { DocumentGenerationError } from "../errors";
import type { IDocumentMetadataFactory } from "./document-metadata-factory.interface";
import type { IDocumentPostProcessor } from "./document-post-processor.interface";
import type { IDocumentPreProcessor } from "./document-pre-processor.interface";
import type { IDocumentRenderer } from "./document-renderer.interface";
import type { IDocumentSideEffect } from "./document-side-effect.interface";
/**
* Servicio de Application que orquesta la generación de documentos.
*
* Flujo inmutable:
* 1. Construcción de metadata
* 2. Pre-processors (short-circuit: cache, idempotencia)
* 3. Render
* 4. Post-processors (firma, watermark, etc.)
* 5. Side-effects (persistencia, métricas) [best-effort]
*/
export class DocumentGenerationService<TSnapshot> {
constructor(
private readonly metadataFactory: IDocumentMetadataFactory<TSnapshot>,
private readonly preProcessors: readonly IDocumentPreProcessor[],
private readonly renderer: IDocumentRenderer<TSnapshot>,
private readonly postProcessor: IDocumentPostProcessor,
private readonly sideEffects: readonly IDocumentSideEffect[]
) {}
async generate(snapshot: TSnapshot): Promise<Result<IDocument, DocumentGenerationError>> {
let metadata: IDocumentMetadata;
// 1. Metadata
try {
metadata = this.metadataFactory.build(snapshot);
} catch (error) {
return Result.fail(DocumentGenerationError.metadata(error));
}
// 2. Pre-processors (cache / short-circuit)
for (const preProcessor of this.preProcessors) {
try {
const cached = await preProcessor.tryResolve(metadata);
if (cached) {
return Result.ok(cached);
}
} catch (error) {
// best-effort: ignorar y continuar
}
}
// 3. Render
let document: IDocument;
try {
document = await this.renderer.render(snapshot, {
format: metadata.format,
languageCode: metadata.languageCode,
filename: metadata.filename,
mimeType: metadata.format === "PDF" ? "application/pdf" : "text/html",
});
} catch (error) {
return Result.fail(DocumentGenerationError.render(error));
}
// 4. Post-processors (transformaciones)
if (this.postProcessor) {
try {
document = await this.postProcessor.process(document, metadata);
} catch (error) {
return Result.fail(DocumentGenerationError.postProcess(error));
}
}
// 5. Side-effects (best-effort)
for (const effect of this.sideEffects) {
try {
await effect.execute(document, metadata);
} catch (err) {
// Importante:
// - Nunca rompe el flujo
// - Logging a nivel infra / observabilidad
const error = DocumentGenerationError.sideEffect(err as Error);
logger.warn(error.message, { error });
}
}
return Result.ok(document);
}
}

View File

@ -0,0 +1,5 @@
import type { IDocumentMetadata } from "../application-models";
export interface IDocumentMetadataFactory<TSnapshot> {
build(snapshot: TSnapshot): IDocumentMetadata;
}

View File

@ -0,0 +1,17 @@
import type { IDocument, IDocumentMetadata } from "../application-models";
import type { IDocumentPostProcessor } from "./document-post-processor.interface";
export class DocumentPostProcessorChain implements IDocumentPostProcessor {
constructor(private readonly processors: readonly IDocumentPostProcessor[]) {}
async process(document: IDocument, metadata: IDocumentMetadata): Promise<IDocument> {
let current = document;
for (const processor of this.processors) {
current = await processor.process(current, metadata);
}
return current;
}
}

View File

@ -0,0 +1,9 @@
import type { IDocument, IDocumentMetadata } from "../application-models";
export interface IDocumentPostProcessor {
/**
* Transforma el documento.
* Debe devolver un nuevo IDocument (inmutabilidad).
*/
process(document: IDocument, metadata: IDocumentMetadata): Promise<IDocument>;
}

View File

@ -0,0 +1,11 @@
import type { IDocument, IDocumentMetadata } from "../application-models";
export interface IDocumentPreProcessor {
/**
* Intenta resolver el documento final.
* - Devuelve IDocument corta el pipeline
* - Devuelve null continuar
* - Lanza error de Application (normalizado por el orquestador)
*/
tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null>;
}

View File

@ -0,0 +1,5 @@
import type { IDocument } from "../application-models";
export interface IDocumentRenderer<TSource> {
render(source: TSource, params?: Record<string, unknown>): Promise<IDocument>;
}

View File

@ -0,0 +1,10 @@
// core/api/application/documents/services/document-side-effect.ts
import type { IDocument, IDocumentMetadata } from "../application-models";
export interface IDocumentSideEffect {
/**
* Ejecuta un efecto secundario (persistencia, métricas, auditoría).
* Los errores deben ser capturados por el orquestador.
*/
execute(document: IDocument, metadata: IDocumentMetadata): Promise<void>;
}

View File

@ -0,0 +1,5 @@
import type { ICompanyCertificateContext } from "../application-models";
export interface IDocumentSigningService {
sign(payload: Buffer, context: ICompanyCertificateContext): Promise<Buffer>;
}

View File

@ -0,0 +1,12 @@
import type { IDocument, IDocumentMetadata } from "../application-models";
export interface IDocumentStorage {
/**
* Persiste un documento generado.
*
* - Side-effect
* - Best-effort
* - Nunca lanza (errores se gestionan internamente)
*/
save(document: IDocument, metadata: IDocumentMetadata): Promise<void>;
}

View File

@ -0,0 +1,13 @@
export * from "./document-cache.interface";
export * from "./document-cache-key-factory";
export * from "./document-generation-error";
export * from "./document-generation-service";
export * from "./document-metadata-factory.interface";
export * from "./document-post-processor.interface";
export * from "./document-post-processor-chain";
export * from "./document-pre-processor.interface";
export * from "./document-renderer.interface";
export * from "./document-side-effect.interface";
export * from "./document-signing-service.interface";
export * from "./document-storage.interface";
export * from "./signing-context-resolver.interface";

View File

@ -0,0 +1,9 @@
import type { ICompanyCertificateContext } from "../application-models";
export interface ISigningContextResolver {
/**
* Resuelve el contexto de firma para una empresa.
* Lanza si no está configurado.
*/
resolveForCompany(companyId: string): Promise<ICompanyCertificateContext | null>;
}

View File

@ -1,12 +0,0 @@
/**
* Errores de capa de aplicación. No deben "filtrarse" a cliente tal cual.
*
* */
export class ApplicationError extends Error {
public readonly layer = "application" as const;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@ -1 +0,0 @@
export * from "./application-error";

View File

@ -1,3 +1,3 @@
export * from "./errors";
export * from "./presenters";
export * from "./documents";
export * from "./renderers";
export * from "./snapshot-builders";

View File

@ -1,7 +0,0 @@
import type { DTO } from "../../../common/types";
export type IPresenterOutputParams = Record<string, unknown>;
export interface IPresenter<TSource, TOutput = DTO> {
toOutput(source: TSource, params?: IPresenterOutputParams): TOutput | Promise<TOutput>;
}

View File

@ -1,11 +0,0 @@
import type { IPresenter, IPresenterOutputParams } from "./presenter.interface";
import type { IPresenterRegistry } from "./presenter-registry.interface";
export abstract class Presenter<TSource = unknown, TOutput = unknown>
implements IPresenter<TSource, TOutput>
{
constructor(protected presenterRegistry: IPresenterRegistry) {
//
}
abstract toOutput(source: TSource, params?: IPresenterOutputParams): TOutput;
}

View File

@ -1,4 +1,4 @@
import { ApplicationError } from "../errors";
import { ApplicationError } from "@repo/rdx-ddd";
import type { IRenderer } from "./renderer.interface";
import type { IRendererRegistry, RendererKey } from "./renderer-registry.interface";

View File

@ -1,4 +1,4 @@
export * from "./presenter";
export * from "./presenter.interface";
export * from "./presenter-registry";
export * from "./presenter-registry.interface";
export * from "./snapshot-builder";
export * from "./snapshot-builder.interface";

View File

@ -1,6 +1,6 @@
import type { DTO } from "@erp/core/common";
import type { IPresenter } from "./presenter.interface";
import type { ISnapshotBuilder } from "./snapshot-builder.interface";
/**
* 🔑 Claves de proyección comunes para seleccionar presenters
@ -25,17 +25,17 @@ export interface IPresenterRegistry {
*/
getPresenter<TSource, F extends PresenterFormat = "DTO">(
key: Omit<PresenterKey, "format"> & { format?: F }
): IPresenter<TSource, PresenterFormatOutputMap[F]>;
): ISnapshotBuilder<TSource, PresenterFormatOutputMap[F]>;
/**
* Registra un mapper de dominio bajo una clave de proyección.
*/
registerPresenter<TSource, TOutput>(
key: PresenterKey,
presenter: IPresenter<TSource, TOutput>
presenter: ISnapshotBuilder<TSource, TOutput>
): this;
registerPresenters(
presenters: Array<{ key: PresenterKey; presenter: IPresenter<unknown, unknown> }>
presenters: Array<{ key: PresenterKey; presenter: ISnapshotBuilder<unknown, unknown> }>
): this;
}

View File

@ -1,10 +1,10 @@
import { ApplicationError } from "../errors";
import { ApplicationError } from "@repo/rdx-ddd";
import type { IPresenter } from "./presenter.interface";
import type { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface";
import type { ISnapshotBuilder } from "./snapshot-builder.interface";
export class InMemoryPresenterRegistry implements IPresenterRegistry {
private registry: Map<string, IPresenter<any, any>> = new Map();
private registry: Map<string, ISnapshotBuilder<any, any>> = new Map();
private _normalizeKey(key: PresenterKey): PresenterKey {
return {
@ -29,13 +29,13 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
private _registerPresenter<TSource, TOutput>(
key: PresenterKey,
presenter: IPresenter<TSource, TOutput>
presenter: ISnapshotBuilder<TSource, TOutput>
): void {
const exactKey = this._buildKey(key);
this.registry.set(exactKey, presenter);
}
getPresenter<TSource, TOutput>(key: PresenterKey): IPresenter<TSource, TOutput> {
getPresenter<TSource, TOutput>(key: PresenterKey): ISnapshotBuilder<TSource, TOutput> {
const exactKey = this._buildKey(key);
// 1) Intentar clave exacta
@ -76,7 +76,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
registerPresenter<TSource, TOutput>(
key: PresenterKey,
presenter: IPresenter<TSource, TOutput>
presenter: ISnapshotBuilder<TSource, TOutput>
): this {
this._registerPresenter(key, presenter);
return this;
@ -85,7 +85,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
* Registro en lote de presentadores.
*/
registerPresenters(
presenters: Array<{ key: PresenterKey; presenter: IPresenter<any, any> }>
presenters: Array<{ key: PresenterKey; presenter: ISnapshotBuilder<any, any> }>
): this {
for (const { key, presenter } of presenters) {
this._registerPresenter(key, presenter);

View File

@ -0,0 +1,5 @@
export type ISnapshotBuilderParams = Readonly<Record<string, unknown>>;
export interface ISnapshotBuilder<TSource, TSnapshot = unknown> {
toOutput(source: TSource, params?: ISnapshotBuilderParams): TSnapshot;
}

View File

@ -0,0 +1,11 @@
import type { IPresenterRegistry } from "./presenter-registry.interface";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "./snapshot-builder.interface";
export abstract class SnapshotBuilder<TSource = unknown, TOutput = unknown>
implements ISnapshotBuilder<TSource, TOutput>
{
constructor(protected snapshotBuilderRegistry: IPresenterRegistry) {
//
}
abstract toOutput(source: TSource, params?: ISnapshotBuilderParams): TOutput;
}

View File

@ -0,0 +1,45 @@
import {
EnvCompanySigningContextResolver,
FastReportExecutableResolver,
FastReportProcessRunner,
FastReportRenderer,
FilesystemDocumentCacheStore,
RestDocumentSigningService,
} from "../documents";
import { FilesystemDocumentStorage } from "../storage";
export function buildCoreDocumentsDI(env: NodeJS.ProcessEnv) {
// Renderers
const frExecutableResolver = new FastReportExecutableResolver(env.FASTREPORT_BIN);
const frProcessRunner = new FastReportProcessRunner();
const fastReportRenderer = new FastReportRenderer(frExecutableResolver, frProcessRunner);
// Signing
const signingContextResolver = new EnvCompanySigningContextResolver(env);
const signingService = new RestDocumentSigningService({
signUrl: String(env.SIGNING_BASE_URL),
timeoutMs: env.SIGNING_TIMEOUT_MS ? Number.parseInt(env.SIGNING_TIMEOUT_MS, 10) : 15_000,
maxRetries: env.SIGNING_MAX_RETRIES ? Number.parseInt(env.SIGNING_MAX_RETRIES, 10) : 2,
});
// Cache para documentos firmados
const cacheStore = new FilesystemDocumentCacheStore(String(env.SIGNED_DOCUMENTS_CACHE_PATH));
// Almancenamiento para documentos firmados
const storage = new FilesystemDocumentStorage(String(env.SIGNED_DOCUMENTS_PATH));
return {
documentRenderers: {
fastReportRenderer,
},
documentSigning: {
signingService,
signingContextResolver,
},
documentStorage: {
cacheStore,
storage,
},
};
}

View File

@ -0,0 +1,2 @@
export * from "./documents.di";
export * from "./transactions.di";

View File

@ -0,0 +1,6 @@
import type { Sequelize } from "sequelize";
import { SequelizeTransactionManager } from "../sequelize";
export const buildTransactionManager = (database: Sequelize) =>
new SequelizeTransactionManager(database);

View File

@ -0,0 +1,5 @@
export interface ICompanySigningContextRecord {
certificateId: string;
certificateSecretName: string;
certificatePasswordSecretName: string;
}

View File

@ -0,0 +1,73 @@
// core/api/infrastructure/documents/certificates/
// env-company-signing-context-store.ts
import type {
ICompanyCertificateContext,
ISigningContextResolver,
} from "@erp/core/api/application";
import type { ICompanySigningContextRecord } from "./company-signing-context-record.interface";
/**
* Implementación de ICompanySigningContextStore basada en
* variables de entorno.
*
* - Infra pura
* - Sin IO externo
* - Ideal para dev / staging / setups simples
*/
export class EnvCompanySigningContextResolver implements ISigningContextResolver {
private readonly records: Record<string, ICompanySigningContextRecord>;
constructor(env: NodeJS.ProcessEnv) {
const raw = env.COMPANY_CERTIFICATES_JSON;
if (!raw) {
this.records = {};
return;
}
try {
const parsed = JSON.parse(raw) as Record<
string,
{
certificateId: string;
certificateSecretName: string;
certificatePasswordSecretName: string;
}
>;
this.records = Object.fromEntries(
Object.entries(parsed).map(([companySlug, cfg]) => [
companySlug,
{
certificateId: cfg.certificateId,
certificateSecretName: cfg.certificateSecretName,
certificatePasswordSecretName: cfg.certificatePasswordSecretName,
},
])
);
} catch {
throw new Error("Invalid COMPANY_CERTIFICATES_JSON format");
}
}
async resolveForCompany(companyId: string): Promise<ICompanyCertificateContext | null> {
/**
* En esta implementación:
* - companyId === companySlug
* - No hay lookup adicional
*/
const record = this.records[companyId];
if (!record) {
return null;
}
return {
companySlug: companyId,
certificateSecretName: record.certificateSecretName,
certificatePasswordSecretName: record.certificatePasswordSecretName,
};
}
}

View File

@ -0,0 +1,2 @@
export * from "./company-signing-context-record.interface";
export * from "./env-company-signing-context-store";

View File

@ -0,0 +1,4 @@
export * from "./certificates";
export * from "./renderers";
export * from "./signing";
export * from "./storage";

View File

@ -1,4 +1,4 @@
import { InfrastructureError } from "../../errors";
import { InfrastructureError } from "../../../errors";
/**
* Error base de FastReport.

View File

@ -15,11 +15,12 @@ import { FastReportExecutableNotFoundError } from "./fastreport-errors";
* 3. Fail-fast si no existe
*/
export class FastReportExecutableResolver {
constructor(private readonly execPath?: string) {}
public resolve(): string {
const fromEnv = process.env.FASTREPORT_BIN;
if (fromEnv) {
this.assertExecutableExists(fromEnv);
return fromEnv;
if (this.execPath) {
this.assertExecutableExists(this.execPath);
return this.execPath;
}
const executableName = this.resolveExecutableName();

View File

@ -1,7 +1,6 @@
import type { ReportStorageKey } from "../../reporting";
export type FastReportRenderOptions = {
templatePath: string;
inputData: unknown;
format: "PDF" | "HTML";
storageKey: ReportStorageKey;
storageKey?: string;
};

View File

@ -0,0 +1,4 @@
export interface FastReportRenderOutput {
payload: Buffer | string;
templateChecksum: string;
}

View File

@ -0,0 +1,179 @@
import { createHash, randomUUID } from "node:crypto";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Renderer } from "../../../../application";
import { FastReportExecutionError, FastReportIOError } from "./fastreport-errors";
import type { FastReportExecutableResolver } from "./fastreport-executable-resolver";
import type { FastReportProcessRunner } from "./fastreport-process-runner";
import type { FastReportRenderOptions } from "./fastreport-render-options.type";
import type { FastReportRenderOutput } from "./fastreport-render-output";
/**
* Clase base para renderers FastReport.
*/
export class FastReportRenderer extends Renderer<unknown, FastReportRenderOutput> {
constructor(
private readonly executableResolver: FastReportExecutableResolver,
private readonly processRunner: FastReportProcessRunner
) {
super();
}
async render(options: FastReportRenderOptions): Promise<FastReportRenderOutput> {
const workDir = path.join(os.tmpdir(), "fastreport", randomUUID());
const inputPath = path.join(workDir, "input.json");
const outputPath = path.join(workDir, options.format === "PDF" ? "output.pdf" : "output.html");
await mkdir(workDir, { recursive: true });
try {
await this.ensureWorkDir(workDir);
await writeFile(inputPath, JSON.stringify(options.inputData), "utf-8");
const executablePath = this.executableResolver.resolve();
await this.processRunner.run(executablePath, {
templatePath: options.templatePath,
data: inputPath,
workdir: outputPath,
format: options.format,
});
const payload = await readFile(outputPath);
return {
payload,
templateChecksum: await this.computeTemplateChecksum(options.templatePath),
};
} catch (error) {
throw new FastReportExecutionError((error as Error).message);
} finally {
await this.safeCleanup(workDir);
}
}
private async safeCleanup(path: string): Promise<void> {
try {
await rm(path, { recursive: true, force: true });
} catch {
// Cleanup best-effort: no throw
}
}
private async ensureWorkDir(path: string): Promise<void> {
try {
await import("node:fs/promises").then((fs) => fs.mkdir(path, { recursive: true }));
} catch (error) {
throw new FastReportIOError((error as Error).message);
}
}
private async computeTemplateChecksum(templatePath: string): Promise<string> {
try {
await access(templatePath);
const content = await readFile(templatePath);
return createHash("sha256").update(content).digest("hex");
} catch (error) {
throw new FastReportExecutionError((error as Error).message);
}
}
}
/*
protected async renderInternal(
options: FastReportRenderOptions
): Promise<FastReportRenderOutput> {
if (process.env.NODE_ENV !== "development") {
// Cache (read-through)
const cached = await this.tryReadFromCache(options.storageKey);
if (cached.isSome()) {
return {
payload: cached.unwrap(),
templateChecksum: "CACHED",
};
}
}
// Resolver plantilla
const templatePath = this.resolveTemplatePath();
console.log("Using FastReport template:", templatePath);
if (!fs.existsSync(templatePath)) {
throw new FastReportTemplateNotFoundError(templatePath);
}
const templateChecksum = this.calculateChecksum(templatePath);
// Llamar a FastReport
const callResult = await this.callFastReportGenerator(options, templatePath);
if (callResult.isFailure) {
throw callResult.error;
}
// Guardar documento generado (best-effort)
await this.storageReport(options.storageKey, callResult.data);
return {
payload: callResult.data,
templateChecksum,
};
}
protected async tryReadFromCache(docKey: ReportStorageKey): Promise<Maybe<Buffer | string>> {
if (await this.reportStorage.exists(docKey)) {
const cached = await this.reportStorage.read(docKey);
return Maybe.some(cached);
}
return Maybe.none();
}
protected async callFastReportGenerator(
options: FastReportRenderOptions,
templatePath: string
): Promise<Result<Buffer | string, FastReportError>> {
const executablePath = this.executableResolver.resolve();
const workdir = this.resolveWorkdir();
const runResult = await this.processRunner.run(executablePath, {
templatePath,
data: JSON.stringify(options.inputData),
format: options.format,
workdir,
});
if (runResult.isFailure) {
return Result.fail(new FastReportExecutionError(runResult.error.message, runResult.error));
}
return Result.ok(runResult.data);
}
protected async storageReport(key: ReportStorageKey, payload: Buffer | string): Promise<void> {
try {
await this.reportStorage.write(key, payload);
} catch (error) {
// ⚠️ Importante: no romper generación por fallo de cache
}
}
protected resolveWorkdir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "fastreport-"));
}
protected abstract resolveTemplatePath(): string;
protected calculateChecksum(filePath: string): string
{
const buffer = fs.readFileSync(filePath);
return crypto.createHash("sha256").update(buffer).digest("hex");
}
}
*/

View File

@ -2,5 +2,4 @@ export * from "./fastreport-errors";
export * from "./fastreport-executable-resolver";
export * from "./fastreport-process-runner";
export * from "./fastreport-render-options.type";
export * from "./fastreport-renderer.base";
export * from "./fastreport-template-resolver";
export * from "./fastreport-renderer";

View File

@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import Handlebars from "handlebars";
import { lookup } from "mime-types";
import { RendererTemplateResolver } from "../renderer-template-resolver";
import { RendererTemplateResolver } from "../renderer-template-resolver-SOBRA";
export class HandlebarsTemplateResolver extends RendererTemplateResolver {
protected readonly hbs = Handlebars.create();

View File

@ -0,0 +1,3 @@
export * from "./fastreport";
export * from "./handlebars";
export * from "./renderer-template-resolver-SOBRA";

View File

@ -1,7 +1,9 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { IRendererTemplateResolver } from "../../application";
import type { IRendererTemplateResolver } from "../../../application";
import { FastReportTemplateNotFoundError } from "./fastreport";
/**
* Resuelve rutas de plantillas para desarrollo y producción.
@ -44,7 +46,7 @@ export abstract class RendererTemplateResolver implements IRendererTemplateResol
const filePath = this.resolveAssetPath(dir, templateName);
if (!existsSync(filePath)) {
throw new Error(
throw new FastReportTemplateNotFoundError(
`Template not found: module=${module} company=${companySlug} name=${templateName}`
);
}

View File

@ -0,0 +1 @@
export * from "./rest-document-signing.service";

View File

@ -0,0 +1,6 @@
export interface RestDocumentSigningConfig {
readonly signUrl: string;
readonly method?: string;
readonly timeoutMs?: number;
readonly maxRetries?: number;
}

View File

@ -0,0 +1,115 @@
import type { IDocumentSigningService } from "@erp/core/api/application";
import type { ICompanyCertificateContext } from "@erp/core/api/application/documents/application-models/company-certificate-context.interface";
import type { RestDocumentSigningConfig } from "./rest-document-signing-config";
import { SimpleCircuitBreaker } from "./simple-circuit-breaker";
/**
* Implementación REST del servicio de firma digital de documentos.
*
* - Infra pura
* - Usa retries limitados
* - Usa circuit breaker simple
* - No expone detalles HTTP a Application
*/
export class RestDocumentSigningService implements IDocumentSigningService {
private readonly signUrl: string;
private readonly method: string;
private readonly timeoutMs: number;
private readonly maxRetries: number;
private readonly circuitBreaker: SimpleCircuitBreaker;
constructor(config: RestDocumentSigningConfig) {
this.signUrl = config.signUrl;
this.method = config.method ?? "POST";
this.timeoutMs = config.timeoutMs ?? 10_000;
this.maxRetries = config.maxRetries ?? 2;
this.circuitBreaker = new SimpleCircuitBreaker();
}
async sign(payload: Buffer, context: ICompanyCertificateContext): Promise<Buffer> {
if (!this.circuitBreaker.canExecute()) {
throw new Error("Document signing service unavailable (circuit open)");
}
try {
const result = await this.withRetries(() => this.callSigningService(payload, context));
this.circuitBreaker.onSuccess();
return result;
} catch (error) {
this.circuitBreaker.onFailure();
throw error;
}
}
private async withRetries<T>(operation: () => Promise<T>): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error) {
attempt++;
if (attempt > this.maxRetries || !this.isRetryableError(error)) {
throw error;
}
await this.delay(200 * attempt);
}
}
}
private async callSigningService(
payload: Buffer,
context: ICompanyCertificateContext
): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(this.signUrl, {
method: this.method,
signal: controller.signal,
body: this.buildFormData(payload, context),
});
if (!response.ok) {
const error = new Error(`Signing service responded with status ${response.status}`);
(error as any).status = response.status;
throw error;
}
return Buffer.from(await response.arrayBuffer());
} finally {
clearTimeout(timeout);
}
}
private buildFormData(payload: Buffer, context: ICompanyCertificateContext): FormData {
const form = new FormData();
form.append("file", new Blob([new Uint8Array(payload)]));
form.append("certificate_secret_name", context.certificateSecretName);
form.append("certificate_password_secret_name", context.certificatePasswordSecretName);
form.append("company_slug", context.companySlug);
return form;
}
private isRetryableError(error: unknown): boolean {
if (error instanceof Error && (error as any).name === "AbortError") {
return true;
}
const status = (error as any)?.status;
return status === 502 || status === 503 || status === 504;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@ -0,0 +1,37 @@
export class SimpleCircuitBreaker {
private failures = 0;
private state: "CLOSED" | "OPEN" = "CLOSED";
private openedAt = 0;
constructor(
private readonly failureThreshold = 3,
private readonly resetTimeoutMs = 30_000
) {}
canExecute(): boolean {
if (this.state === "CLOSED") {
return true;
}
if (Date.now() - this.openedAt > this.resetTimeoutMs) {
this.state = "CLOSED";
this.failures = 0;
return true;
}
return false;
}
onSuccess(): void {
this.failures = 0;
this.state = "CLOSED";
}
onFailure(): void {
this.failures++;
if (this.failures >= this.failureThreshold) {
this.state = "OPEN";
this.openedAt = Date.now();
}
}
}

View File

@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { IDocument } from "@erp/core/api/application";
import type { IDocumentCacheStore } from "@erp/core/api/application/documents/services/document-cache.interface";
/**
* Cache técnica de documentos firmados basada en filesystem.
*
* - Best-effort
* - Nunca lanza
* - Cachea SOLO documentos firmados
*/
export class FilesystemDocumentCacheStore implements IDocumentCacheStore {
constructor(private readonly basePath: string) {}
async get(cacheKey: string): Promise<IDocument | null> {
try {
const dir = this.resolveDir(cacheKey);
const payload = await readFile(join(dir, "payload.bin"));
const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf-8"));
return {
payload,
mimeType: meta.mimeType,
filename: meta.filename,
};
} catch {
// cualquier fallo => cache miss
return null;
}
}
async set(cacheKey: string, document: IDocument): Promise<void> {
try {
const dir = this.resolveDir(cacheKey);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "payload.bin"), document.payload);
await writeFile(
join(dir, "meta.json"),
JSON.stringify(
{
mimeType: document.mimeType,
filename: document.filename,
},
null,
2
)
);
} catch {
// best-effort: ignorar
}
}
private resolveDir(cacheKey: string): string {
const hash = createHash("sha256").update(cacheKey).digest("hex");
return join(this.basePath, hash);
}
}

View File

@ -0,0 +1 @@
export * from "./filesystem-document-cache-store";

View File

@ -0,0 +1,11 @@
import { InfrastructureError } from "./infrastructure-errors";
export class InfrastructureAPIContractError extends InfrastructureError {
public readonly code = "API_CONTRACT_VIOLATION" as const;
constructor(message = "API contract violation", options?: ErrorOptions) {
super(message, options);
}
}
export const isInfrastructureAPIContractError = (e: unknown): e is InfrastructureAPIContractError =>
e instanceof InfrastructureAPIContractError;

View File

@ -18,22 +18,22 @@ import {
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import { isSchemaError } from "../../../common/schemas";
import { type DocumentGenerationError, isDocumentGenerationError } from "../../application";
import {
type DuplicateEntityError,
type EntityNotFoundError,
isDuplicateEntityError,
isEntityNotFoundError,
} from "../../domain";
import { type FastReportError, isFastReportError } from "../documents";
import {
type InfrastructureRepositoryError,
type InfrastructureUnavailableError,
isInfrastructureRepositoryError,
isInfrastructureUnavailableError,
} from "../errors";
import {
type FastReportError,
isFastReportError,
} from "../renderers/fast-report/fastreport-errors";
import type { InfrastructureAPIContractError } from "../errors/infrastructure-api-contract-error";
import {
ApiError,
@ -164,6 +164,18 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
},
// 5.5) Errores de FastReport inesperados
{
priority: 55,
matches: (e) => isDocumentGenerationError(e),
build: (e) => {
const error = e as DocumentGenerationError;
const title =
error.documentErrorType === "METADATA"
? "Invalid document render error"
: "Unexcepted document render error";
return new InternalApiError(error.message, title);
},
},
{
priority: 55,
matches: (e) => isFastReportError(e),
@ -193,6 +205,14 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
matches: (e): e is Error => e instanceof Error && e.name === "ForbiddenError",
build: (e) => new ForbiddenApiError((e as Error).message || "Forbidden"),
},
// 8) API contract violation
{
priority: 30,
matches: (e) => isSchemaError(e),
build: (e) =>
new InternalApiError((e as InfrastructureAPIContractError).message, "API contract violation"),
},
];
// Fallback genérico (500)

View File

@ -1,8 +1,9 @@
export * from "./database";
export * from "./di";
export * from "./documents";
export * from "./errors";
export * from "./express";
export * from "./logger";
export * from "./mappers";
export * from "./renderers";
export * from "./reporting";
export * from "./sequelize";
export * from "./storage";

View File

@ -1,5 +1,6 @@
import { InfrastructureError } from "../errors";
import {
import type {
IMapperRegistry,
MapperDomainKey,
MapperKey,

View File

@ -1,140 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Maybe, Result } from "@repo/rdx-utils";
import { Renderer } from "../../../application";
import type { ReportStorage, ReportStorageKey } from "../../reporting";
import {
type FastReportError,
FastReportExecutionError,
FastReportTemplateNotFoundError,
} from "./fastreport-errors";
import type { FastReportExecutableResolver } from "./fastreport-executable-resolver";
import type { FastReportProcessRunner } from "./fastreport-process-runner";
import type { FastReportRenderOptions } from "./fastreport-render-options.type";
import type { FastReportTemplateResolver } from "./fastreport-template-resolver";
export type FastReportRenderOutput = Result<
{ payload: Buffer | string; templateChecksum: string },
FastReportError
>;
/**
* Clase base para renderers FastReport.
*/
export abstract class FastReportRenderer extends Renderer<unknown, FastReportRenderOutput> {
constructor(
protected readonly executableResolver: FastReportExecutableResolver,
protected readonly processRunner: FastReportProcessRunner,
protected readonly templateResolver: FastReportTemplateResolver,
protected readonly reportStorage: ReportStorage
) {
super();
}
/**
* Punto de entrada común para renderizado.
*/
protected async renderInternal(
options: FastReportRenderOptions
): Promise<FastReportRenderOutput> {
if (process.env.NODE_ENV !== "development") {
// Cache (read-through)
const cached = await this.tryReadFromCache(options.storageKey);
if (cached.isSome()) {
return Result.ok({
payload: cached.unwrap(),
templateChecksum: "CACHED",
});
}
}
try {
// Resolver plantilla
const templatePath = this.resolveTemplatePath();
console.log("Using FastReport template:", templatePath);
if (!fs.existsSync(templatePath)) {
return Result.fail(new FastReportTemplateNotFoundError(templatePath));
}
const templateChecksum = this.calculateChecksum(templatePath);
// Llamar a FastReport
const callResult = await this.callFastReportGenerator(options, templatePath);
if (callResult.isFailure) {
return Result.fail(callResult.error);
}
// Guardar documento generado (best-effort)
await this.storageReport(options.storageKey, callResult.data);
return Result.ok({
payload: callResult.data,
templateChecksum,
});
} catch (error) {
console.error("FastReport rendering error:", error);
return Result.fail(
new FastReportExecutionError("Unexpected FastReport rendering error", error as Error)
);
}
}
protected async tryReadFromCache(docKey: ReportStorageKey): Promise<Maybe<Buffer | string>> {
if (await this.reportStorage.exists(docKey)) {
const cached = await this.reportStorage.read(docKey);
return Maybe.some(cached);
}
return Maybe.none();
}
protected async callFastReportGenerator(
options: FastReportRenderOptions,
templatePath: string
): Promise<Result<Buffer | string, FastReportError>> {
const executablePath = this.executableResolver.resolve();
const workdir = this.resolveWorkdir();
const runResult = await this.processRunner.run(executablePath, {
templatePath,
data: JSON.stringify(options.inputData),
format: options.format,
workdir,
});
if (runResult.isFailure) {
return Result.fail(new FastReportExecutionError(runResult.error.message, runResult.error));
}
return Result.ok(runResult.data);
}
protected async storageReport(key: ReportStorageKey, payload: Buffer | string): Promise<void> {
try {
await this.reportStorage.write(key, payload);
} catch (error) {
// ⚠️ Importante: no romper generación por fallo de cache
}
}
protected resolveWorkdir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "fastreport-"));
}
/**
* Cada renderer concreto decide
* qué plantilla usar.
*/
protected abstract resolveTemplatePath(): string;
protected calculateChecksum(filePath: string): string {
const buffer = fs.readFileSync(filePath);
return crypto.createHash("sha256").update(buffer).digest("hex");
}
}

View File

@ -1,3 +0,0 @@
import { RendererTemplateResolver } from "../renderer-template-resolver";
export class FastReportTemplateResolver extends RendererTemplateResolver {}

View File

@ -1,3 +0,0 @@
export * from "./fast-report";
export * from "./handlebars";
export * from "./renderer-template-resolver";

View File

@ -1,40 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { buildSafePath } from "@repo/rdx-utils";
import type { ReportStorage, ReportStorageKey } from "./report-storage";
export class FileSystemReportStorage implements ReportStorage {
public constructor(private readonly basePath: string) {}
public async exists(docKey: ReportStorageKey): Promise<boolean> {
try {
await fs.access(this.resolvePath(docKey));
return true;
} catch {
return false;
}
}
public async read(docKey: ReportStorageKey): Promise<Buffer | string> {
const filePath = this.resolvePath(docKey);
return docKey.format === "PDF" ? fs.readFile(filePath) : fs.readFile(filePath, "utf-8");
}
public async write(docKey: ReportStorageKey, payload: Buffer | string): Promise<void> {
const filePath = this.resolvePath(docKey);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, payload);
}
private resolvePath(key: ReportStorageKey): string {
const ext = key.format.toLowerCase();
return buildSafePath({
basePath: this.basePath,
segments: [key.documentType.toLowerCase()],
filename: `${key.documentId}.${ext}`,
});
}
}

View File

@ -1,2 +0,0 @@
export * from "./filesystem-report-storage";
export * from "./report-storage";

View File

@ -1,11 +0,0 @@
export type ReportStorageKey = {
documentType: string;
documentId: string;
format: "PDF" | "HTML";
};
export interface ReportStorage {
exists(docKey: ReportStorageKey): Promise<boolean>;
read(docKey: ReportStorageKey): Promise<Buffer | string>;
write(docKey: ReportStorageKey, payload: Buffer | string): Promise<void>;
}

View File

@ -1,8 +1,9 @@
import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import { IAggregateRootRepository, UniqueID } from "@repo/rdx-ddd";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { IAggregateRootRepository, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { FindOptions, ModelDefined, Sequelize, Transaction } from "sequelize";
import { IMapperRegistry } from "../mappers";
import type { FindOptions, ModelDefined, Sequelize, Transaction } from "sequelize";
import type { IMapperRegistry } from "../mappers";
export abstract class SequelizeRepository<T> implements IAggregateRootRepository<T> {
protected readonly _database!: Sequelize;

View File

@ -0,0 +1,50 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { IDocument, IDocumentStorage } from "../../application";
/**
* Persistencia best-effort de documentos basada en filesystem.
*
* - Infra pura
* - Nunca lanza
* - No afecta al flujo del caso de uso
*/
export class FilesystemDocumentStorage implements IDocumentStorage {
public constructor(private readonly basePath: string) {}
async save(document: IDocument, metadata: Record<string, unknown>): Promise<void> {
try {
const dir = this.resolveDir(metadata);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "document.bin"), document.payload);
await writeFile(
path.join(dir, "document.meta.json"),
JSON.stringify(
{
mimeType: document.mimeType,
filename: document.filename,
metadata,
},
null,
2
)
);
} catch {
// best-effort: ignorar errores
}
}
private resolveDir(metadata: Record<string, unknown>): string {
/**
* El storage NO decide claves semánticas.
* Se limita a generar un path técnico estable.
*/
const hash = createHash("sha256").update(JSON.stringify(metadata)).digest("hex");
return path.join(this.basePath, hash);
}
}

View File

@ -0,0 +1 @@
export * from "./filesystem-signed-document-storage";

View File

@ -9,7 +9,7 @@ import { MetadataSchema } from "./metadata.dto";
* @returns Zod schema para ListViewDTO<T>
*/
export const PaginationSchema = z.object({
page: z.number().int().min(1, "Page must be a positive integer"),
page: z.number().int().min(0, "Page must be a positive integer"),
per_page: z.number().int().min(1, "Items per page must be a positive integer"),
total_pages: z.number().int().min(0, "Total pages must be a non-negative integer"),
total_items: z.number().int().min(0, "Total items must be a non-negative integer"),

View File

@ -1 +1,2 @@
export * from "./core.schemas";
export * from "./schema-error";

View File

@ -0,0 +1,3 @@
import { ZodError } from "zod";
export const isSchemaError = (e: unknown): e is ZodError => e instanceof ZodError;

View File

@ -1,3 +1,5 @@
export * from "./presenters";
export * from "./services";
//export * from "./services";
//export * from "./snapshot-builders";
export * from "./issued-invoices";
export * from "./use-cases";

View File

@ -0,0 +1,3 @@
export * from "./issued-invoice-document.model";
export * from "./report-cache-key";
export * from "./snapshots";

View File

@ -0,0 +1,33 @@
/**
* Documento legal generado para una factura emitida.
*
*/
export interface IIssuedInvoiceDocument {
payload: Buffer;
mimeType: string;
filename: string;
}
export class IssuedInvoiceDocument implements IIssuedInvoiceDocument {
public readonly payload: Buffer;
public readonly mimeType: string;
public readonly filename: string;
constructor(params: {
payload: Buffer;
filename: string;
}) {
if (!params.payload || params.payload.length === 0) {
throw new Error("IssuedInvoiceDocument payload cannot be empty");
}
if (!params.filename.toLowerCase().endsWith(".pdf")) {
throw new Error("IssuedInvoiceDocument filename must end with .pdf");
}
this.payload = params.payload;
this.mimeType = "application/pdf";
this.filename = params.filename;
}
}

View File

@ -0,0 +1,59 @@
import type { UniqueID } from "@repo/rdx-ddd";
/**
* Clave determinista que identifica de forma única
* un documento legal generado.
*
* Encapsula la regla de idempotencia del caso de uso.
*/
export class ReportCacheKey {
private readonly value: string;
private constructor(value: string) {
this.value = value;
}
static forIssuedInvoice(params: {
companyId: UniqueID;
invoiceId: UniqueID;
language: string;
canonicalModelHash: string;
templateChecksum: string;
rendererVersion: string;
signingProviderVersion: string;
}): ReportCacheKey {
const {
companyId,
invoiceId,
language,
canonicalModelHash,
templateChecksum,
rendererVersion,
signingProviderVersion,
} = params;
// Fail-fast: campos obligatorios
for (const [key, value] of Object.entries(params)) {
if (!value || String(value).trim() === "") {
throw new Error(`ReportCacheKey missing field: ${key}`);
}
}
return new ReportCacheKey(
[
"issued-invoice",
companyId,
invoiceId,
language,
canonicalModelHash,
templateChecksum,
rendererVersion,
signingProviderVersion,
].join("__")
);
}
toString(): string {
return this.value;
}
}

View File

@ -0,0 +1,4 @@
export * from "./issued-invoice-full-snapshot";
export * from "./issued-invoice-item-full-snapshot";
export * from "./issued-invoice-recipient-full-snapshot";
export * from "./issued-invoice-verifactu-full-snapshot";

View File

@ -0,0 +1,69 @@
import type { IssuedInvoiceItemFullSnapshot } from "./issued-invoice-item-full-snapshot";
import type { IssuedInvoiceRecipientFullSnapshot } from "./issued-invoice-recipient-full-snapshot";
import type { IssuedInvoiceVerifactuFullSnapshot } from "./issued-invoice-verifactu-full-snapshot";
export interface IssuedInvoiceFullSnapshot {
id: string;
company_id: string;
is_proforma: "true" | "false";
invoice_number: string;
status: string;
series: string;
invoice_date: string;
operation_date: string;
reference: string;
description: string;
notes: string;
language_code: string;
currency_code: string;
customer_id: string;
recipient: IssuedInvoiceRecipientFullSnapshot;
payment_method?: {
payment_id: string;
payment_description: string;
};
subtotal_amount: { value: string; scale: string; currency_code: string };
items_discount_amount: { value: string; scale: string; currency_code: string };
discount_percentage: { value: string; scale: string };
discount_amount: { value: string; scale: string; currency_code: string };
taxable_amount: { value: string; scale: string; currency_code: string };
iva_amount: { value: string; scale: string; currency_code: string };
rec_amount: { value: string; scale: string; currency_code: string };
retention_amount: { value: string; scale: string; currency_code: string };
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
taxes: Array<{
taxable_amount: { value: string; scale: string; currency_code: string };
iva_code: string;
iva_percentage: { value: string; scale: string };
iva_amount: { value: string; scale: string; currency_code: string };
rec_code: string;
rec_percentage: { value: string; scale: string };
rec_amount: { value: string; scale: string; currency_code: string };
retention_code: string;
retention_percentage: { value: string; scale: string };
retention_amount: { value: string; scale: string; currency_code: string };
taxes_amount: { value: string; scale: string; currency_code: string };
}>;
verifactu: IssuedInvoiceVerifactuFullSnapshot;
items: IssuedInvoiceItemFullSnapshot[];
metadata?: Record<string, string>;
}

View File

@ -0,0 +1,34 @@
export interface IssuedInvoiceItemFullSnapshot {
id: string;
is_valued: string;
position: string;
description: string;
quantity: { value: string; scale: string };
unit_amount: { value: string; scale: string; currency_code: string };
subtotal_amount: { value: string; scale: string; currency_code: string };
discount_percentage: { value: string; scale: string };
discount_amount: { value: string; scale: string; currency_code: string };
global_discount_percentage: { value: string; scale: string };
global_discount_amount: { value: string; scale: string; currency_code: string };
taxable_amount: { value: string; scale: string; currency_code: string };
iva_code: string;
iva_percentage: { value: string; scale: string };
iva_amount: { value: string; scale: string; currency_code: string };
rec_code: string;
rec_percentage: { value: string; scale: string };
rec_amount: { value: string; scale: string; currency_code: string };
retention_code: string;
retention_percentage: { value: string; scale: string };
retention_amount: { value: string; scale: string; currency_code: string };
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
}

View File

@ -0,0 +1,11 @@
export interface IssuedInvoiceRecipientFullSnapshot {
id: string;
name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postal_code: string;
country: string;
}

View File

@ -0,0 +1,6 @@
export interface IssuedInvoiceVerifactuFullSnapshot {
id: string;
status: string;
url: string;
qr_code: string;
}

View File

@ -0,0 +1,3 @@
export * from "./full";
export * from "./list";
export * from "./report";

View File

@ -0,0 +1 @@
export * from "./issued-invoice-list-item-snapshot";

View File

@ -0,0 +1,46 @@
export interface IssuedInvoiceListItemSnapshot {
id: string;
company_id: string;
is_proforma: boolean;
customer_id: string;
invoice_number: string;
status: string;
series: string;
invoice_date: string;
operation_date: string;
language_code: string;
currency_code: string;
reference: string;
description: string;
recipient: {
tin: string;
name: string;
street: string;
street2: string;
city: string;
postal_code: string;
province: string;
country: string;
};
subtotal_amount: { value: string; scale: string; currency_code: string };
discount_percentage: { value: string; scale: string };
discount_amount: { value: string; scale: string; currency_code: string };
taxable_amount: { value: string; scale: string; currency_code: string };
taxes_amount: { value: string; scale: string; currency_code: string };
total_amount: { value: string; scale: string; currency_code: string };
verifactu: {
status: string;
url: string;
qr_code: string;
};
metadata?: Record<string, string>;
}

View File

@ -0,0 +1,3 @@
export * from "./issued-invoice-report-item-snapshot";
export * from "./issued-invoice-report-snapshot";
export * from "./issued-invoice-report-tax-snapshot";

View File

@ -0,0 +1,11 @@
export interface IssuedInvoiceReportItemSnapshot {
description: string;
quantity: string;
unit_amount: string;
subtotal_amount: string;
discount_percentage: string;
discount_amount: string;
taxable_amount: string;
taxes_amount: string;
total_amount: string;
}

View File

@ -0,0 +1,38 @@
import type { IssuedInvoiceReportItemSnapshot } from "./issued-invoice-report-item-snapshot";
import type { IssuedInvoiceReportTaxSnapshot } from "./issued-invoice-report-tax-snapshot";
export interface IssuedInvoiceReportSnapshot {
id: string;
company_id: string;
invoice_number: string;
series: string;
status: string;
language_code: string;
currency_code: string;
invoice_date: string;
payment_method: string;
recipient: {
name: string;
tin: string;
format_address: string;
};
items: IssuedInvoiceReportItemSnapshot[];
taxes: IssuedInvoiceReportTaxSnapshot[];
subtotal_amount: string;
discount_percentage: string;
discount_amount: string;
taxable_amount: string;
taxes_amount: string;
total_amount: string;
verifactu: {
status: string;
url: string;
qr_code: string;
};
}

View File

@ -0,0 +1,17 @@
export interface IssuedInvoiceReportTaxSnapshot {
taxable_amount: string;
iva_code: string;
iva_percentage: string;
iva_amount: string;
rec_code: string;
rec_percentage: string;
rec_amount: string;
retention_code: string;
retention_percentage: string;
retention_amount: string;
taxes_amount: string;
}

View File

@ -0,0 +1,25 @@
import type { IDocumentSigningService } from "../../services";
import {
type IIssuedInvoiceDocumentRenderer,
IssuedInvoiceDocumentReportService,
} from "../services";
export const buildIssuedInvoiceDocumentService = (
renderer: IIssuedInvoiceDocumentRenderer,
signingService: IDocumentSigningService,
certificateResolver: ICompanyCertificateResolver
) => {
const { fastReport } = renderers;
const issuedInvoiceReportRenderer = new IssuedInvoiceFastReportRenderer(
fastReport.executableResolver,
fastReport.processRunner,
fastReport.templateResolver,
fastReport.reportStorage
);
const issuedInvoiceDocumentRenderer = new IssuedInvoiceDocumentRenderer(
issuedInvoiceReportRenderer
);
return new IssuedInvoiceDocumentReportService(renderer, signingService, certificateResolver);
};

View File

@ -0,0 +1,8 @@
import type { ICustomerInvoiceRepository } from "../../../domain";
import { type IIssuedInvoiceFinder, IssuedInvoiceFinder } from "../services/issued-invoice-finder";
export function buildIssuedInvoiceFinder(
repository: ICustomerInvoiceRepository
): IIssuedInvoiceFinder {
return new IssuedInvoiceFinder(repository);
}

View File

@ -0,0 +1,4 @@
export * from "./documents.di";
export * from "./finder.di";
export * from "./snapshot-builders.di";
export * from "./use-cases.di";

View File

@ -0,0 +1,43 @@
// application/issued-invoices/di/snapshot-builders.di.ts
import { IssuedInvoiceListItemSnapshotBuilder } from "../snapshot-builders";
import {
IssuedInvoiceFullSnapshotBuilder,
IssuedInvoiceItemsFullSnapshotBuilder,
IssuedInvoiceRecipientFullSnapshotBuilder,
IssuedInvoiceVerifactuFullSnapshotBuilder,
} from "../snapshot-builders/full";
import {
IssuedInvoiceItemReportSnapshotBuilder,
IssuedInvoiceReportSnapshotBuilder,
IssuedInvoiceTaxReportSnapshotBuilder,
} from "../snapshot-builders/report";
export function buildIssuedInvoiceSnapshotBuilders() {
const itemsBuilder = new IssuedInvoiceItemsFullSnapshotBuilder();
const recipientBuilder = new IssuedInvoiceRecipientFullSnapshotBuilder();
const verifactuBuilder = new IssuedInvoiceVerifactuFullSnapshotBuilder();
const fullSnapshotBuilder = new IssuedInvoiceFullSnapshotBuilder(
itemsBuilder,
recipientBuilder,
verifactuBuilder
);
const listSnapshotBuilder = new IssuedInvoiceListItemSnapshotBuilder();
const itemsReportBuilder = new IssuedInvoiceItemReportSnapshotBuilder();
const taxesReportBuilder = new IssuedInvoiceTaxReportSnapshotBuilder();
const reportSnapshotBuilder = new IssuedInvoiceReportSnapshotBuilder(
itemsReportBuilder,
taxesReportBuilder
);
return {
full: fullSnapshotBuilder,
list: listSnapshotBuilder,
report: reportSnapshotBuilder,
};
}

View File

@ -0,0 +1,51 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IIssuedInvoiceDocumentReportService, IIssuedInvoiceFinder } from "../services";
import type { IIssuedInvoiceListItemSnapshotBuilder } from "../snapshot-builders";
import type { IIssuedInvoiceFullSnapshotBuilder } from "../snapshot-builders/full";
import type { IIssuedInvoiceReportSnapshotBuilder } from "../snapshot-builders/report";
import {
GetIssuedInvoiceByIdUseCase,
ListIssuedInvoicesUseCase,
ReportIssuedInvoiceUseCase,
} from "../use-cases";
export function buildGetIssuedInvoiceByIdUseCase(deps: {
finder: IIssuedInvoiceFinder;
fullSnapshotBuilder: IIssuedInvoiceFullSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new GetIssuedInvoiceByIdUseCase(
deps.finder,
deps.fullSnapshotBuilder,
deps.transactionManager
);
}
export function buildListIssuedInvoicesUseCase(deps: {
finder: IIssuedInvoiceFinder;
itemSnapshotBuilder: IIssuedInvoiceListItemSnapshotBuilder;
transactionManager: ITransactionManager;
}) {
return new ListIssuedInvoicesUseCase(
deps.finder,
deps.itemSnapshotBuilder,
deps.transactionManager
);
}
export function buildReportIssuedInvoiceUseCase(deps: {
finder: IIssuedInvoiceFinder;
fullSnapshotBuilder: IIssuedInvoiceFullSnapshotBuilder;
reportSnapshotBuilder: IIssuedInvoiceReportSnapshotBuilder;
documentService: IIssuedInvoiceDocumentReportService;
transactionManager: ITransactionManager;
}) {
return new ReportIssuedInvoiceUseCase(
deps.finder,
deps.fullSnapshotBuilder,
deps.reportSnapshotBuilder,
deps.documentService,
deps.transactionManager
);
}

View File

@ -0,0 +1 @@
export * from "./sign-document-command";

View File

@ -0,0 +1,15 @@
/**
* DTO de Application para solicitar la firma de un documento.
*
* Se utiliza exclusivamente para intercambiar datos con la capa
* de infraestructura (DocumentSigningService).
*
* No contiene lógica ni validaciones de negocio.
*/
export interface SignDocumentCommand {
/** PDF sin firmar */
readonly file: Buffer;
/** Identificador estable de la empresa */
readonly companyId: string;
}

View File

@ -0,0 +1,5 @@
export * from "./application-models";
export * from "./di";
export * from "./services";
export * from "./snapshot-builders";
export * from "./use-cases";

View File

@ -0,0 +1,5 @@
export * from "./issued-invoice-document-generator.interface";
export * from "./issued-invoice-document-metadata-factory";
export * from "./issued-invoice-document-renderer.interface";
export * from "./issued-invoice-finder";
export * from "./issued-invoice-report-snapshot-builder";

View File

@ -0,0 +1,6 @@
import type { DocumentGenerationService } from "@erp/core/api";
import type { IssuedInvoiceReportSnapshot } from "../application-models";
export interface IssuedInvoiceDocumentGeneratorService
extends DocumentGenerationService<IssuedInvoiceReportSnapshot> {}

Some files were not shown because too many files have changed in this diff Show More