.
This commit is contained in:
parent
86253f5dcd
commit
dc204237b1
@ -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 = () => {
|
||||
|
||||
97
biome.json
97
biome.json
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export interface IDocument {
|
||||
readonly payload: Buffer;
|
||||
readonly mimeType: string;
|
||||
readonly filename: string;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./company-certificate-context.interface";
|
||||
export * from "./document.interface";
|
||||
export * from "./document-metadata.interface";
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./document-generation-error";
|
||||
2
modules/core/src/api/application/documents/index.ts
Normal file
2
modules/core/src/api/application/documents/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./application-models";
|
||||
export * from "./services";
|
||||
@ -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(":");
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import type { IDocumentMetadata } from "../application-models";
|
||||
|
||||
export interface IDocumentMetadataFactory<TSnapshot> {
|
||||
build(snapshot: TSnapshot): IDocumentMetadata;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import type { IDocument } from "../application-models";
|
||||
|
||||
export interface IDocumentRenderer<TSource> {
|
||||
render(source: TSource, params?: Record<string, unknown>): Promise<IDocument>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import type { ICompanyCertificateContext } from "../application-models";
|
||||
|
||||
export interface IDocumentSigningService {
|
||||
sign(payload: Buffer, context: ICompanyCertificateContext): Promise<Buffer>;
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
13
modules/core/src/api/application/documents/services/index.ts
Normal file
13
modules/core/src/api/application/documents/services/index.ts
Normal 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";
|
||||
@ -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>;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export * from "./application-error";
|
||||
@ -1,3 +1,3 @@
|
||||
export * from "./errors";
|
||||
export * from "./presenters";
|
||||
export * from "./documents";
|
||||
export * from "./renderers";
|
||||
export * from "./snapshot-builders";
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
@ -0,0 +1,5 @@
|
||||
export type ISnapshotBuilderParams = Readonly<Record<string, unknown>>;
|
||||
|
||||
export interface ISnapshotBuilder<TSource, TSnapshot = unknown> {
|
||||
toOutput(source: TSource, params?: ISnapshotBuilderParams): TSnapshot;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
45
modules/core/src/api/infrastructure/di/documents.di.ts
Normal file
45
modules/core/src/api/infrastructure/di/documents.di.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
2
modules/core/src/api/infrastructure/di/index.ts
Normal file
2
modules/core/src/api/infrastructure/di/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./documents.di";
|
||||
export * from "./transactions.di";
|
||||
@ -0,0 +1,6 @@
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import { SequelizeTransactionManager } from "../sequelize";
|
||||
|
||||
export const buildTransactionManager = (database: Sequelize) =>
|
||||
new SequelizeTransactionManager(database);
|
||||
@ -0,0 +1,5 @@
|
||||
export interface ICompanySigningContextRecord {
|
||||
certificateId: string;
|
||||
certificateSecretName: string;
|
||||
certificatePasswordSecretName: string;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./company-signing-context-record.interface";
|
||||
export * from "./env-company-signing-context-store";
|
||||
4
modules/core/src/api/infrastructure/documents/index.ts
Normal file
4
modules/core/src/api/infrastructure/documents/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./certificates";
|
||||
export * from "./renderers";
|
||||
export * from "./signing";
|
||||
export * from "./storage";
|
||||
@ -1,4 +1,4 @@
|
||||
import { InfrastructureError } from "../../errors";
|
||||
import { InfrastructureError } from "../../../errors";
|
||||
|
||||
/**
|
||||
* Error base de FastReport.
|
||||
@ -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();
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReportStorageKey } from "../../reporting";
|
||||
|
||||
export type FastReportRenderOptions = {
|
||||
templatePath: string;
|
||||
inputData: unknown;
|
||||
format: "PDF" | "HTML";
|
||||
storageKey: ReportStorageKey;
|
||||
storageKey?: string;
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export interface FastReportRenderOutput {
|
||||
payload: Buffer | string;
|
||||
templateChecksum: string;
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@ -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";
|
||||
@ -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();
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./fastreport";
|
||||
export * from "./handlebars";
|
||||
export * from "./renderer-template-resolver-SOBRA";
|
||||
@ -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}`
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./rest-document-signing.service";
|
||||
@ -0,0 +1,6 @@
|
||||
export interface RestDocumentSigningConfig {
|
||||
readonly signUrl: string;
|
||||
readonly method?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxRetries?: number;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./filesystem-document-cache-store";
|
||||
@ -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;
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { InfrastructureError } from "../errors";
|
||||
import {
|
||||
|
||||
import type {
|
||||
IMapperRegistry,
|
||||
MapperDomainKey,
|
||||
MapperKey,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import { RendererTemplateResolver } from "../renderer-template-resolver";
|
||||
|
||||
export class FastReportTemplateResolver extends RendererTemplateResolver {}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from "./fast-report";
|
||||
export * from "./handlebars";
|
||||
export * from "./renderer-template-resolver";
|
||||
@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./filesystem-report-storage";
|
||||
export * from "./report-storage";
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
1
modules/core/src/api/infrastructure/storage/index.ts
Normal file
1
modules/core/src/api/infrastructure/storage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./filesystem-signed-document-storage";
|
||||
@ -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"),
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./core.schemas";
|
||||
export * from "./schema-error";
|
||||
|
||||
3
modules/core/src/common/schemas/schema-error.ts
Normal file
3
modules/core/src/common/schemas/schema-error.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export const isSchemaError = (e: unknown): e is ZodError => e instanceof ZodError;
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./issued-invoice-document.model";
|
||||
export * from "./report-cache-key";
|
||||
export * from "./snapshots";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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>;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
export interface IssuedInvoiceVerifactuFullSnapshot {
|
||||
id: string;
|
||||
status: string;
|
||||
url: string;
|
||||
qr_code: string;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./full";
|
||||
export * from "./list";
|
||||
export * from "./report";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./issued-invoice-list-item-snapshot";
|
||||
@ -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>;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./issued-invoice-report-item-snapshot";
|
||||
export * from "./issued-invoice-report-snapshot";
|
||||
export * from "./issued-invoice-report-tax-snapshot";
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from "./documents.di";
|
||||
export * from "./finder.di";
|
||||
export * from "./snapshot-builders.di";
|
||||
export * from "./use-cases.di";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sign-document-command";
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export * from "./application-models";
|
||||
export * from "./di";
|
||||
export * from "./services";
|
||||
export * from "./snapshot-builders";
|
||||
export * from "./use-cases";
|
||||
@ -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";
|
||||
@ -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
Loading…
Reference in New Issue
Block a user