Proformas recurrentes

This commit is contained in:
David Arranz 2026-08-31 20:51:27 +02:00
parent 288b134ec8
commit a746bb97ec
221 changed files with 15842 additions and 1528 deletions

View File

@ -1,4 +1,4 @@
import type { BaseConfigType } from "@erp/core/api";
import type { BaseConfigType, DatabaseConfig } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import dotenv from "dotenv";
@ -13,27 +13,21 @@ let _config: ConfigType | null = null;
export type ConfigType = BaseConfigType & {
env: string;
timezone: string;
server: {
port: number;
apiBasePath: string;
frontendUrl: string;
trustProxy: number;
timezone: string;
};
database: {
dialect: "mysql" | "postgres" | "sqlite" | "mariadb" | "mssql" | "db2" | "snowflake" | "oracle";
host: string;
port: number;
name: string;
user: string;
password: string;
logging: boolean;
ssl: boolean;
database: DatabaseConfig & {
syncMode: string;
};
warmup: { timeoutMs: number; strict: boolean };
auth: {
jwt: {
secret: string;
@ -89,13 +83,13 @@ export const initConfig = (logger: ILogger): ConfigType => {
_config = {
env: env.NODE_ENV,
timezone: env.TIMEZONE,
server: {
port: env.PORT,
apiBasePath: env.API_BASE_PATH,
frontendUrl: env.FRONTEND_URL,
trustProxy: env.TRUST_PROXY,
timezone: env.TIMEZONE,
},
database: {
@ -107,9 +101,12 @@ export const initConfig = (logger: ILogger): ConfigType => {
password: env.DB_PASS,
logging: env.DB_LOGGING,
ssl: env.DB_SSL,
timezone: env.TIMEZONE,
syncMode: env.DB_SYNC_MODE,
},
warmup: { timeoutMs: env.WARMUP_TIMEOUT_MS, strict: env.WARMUP_STRICT },
auth: {
jwt: {
secret: env.JWT_SECRET,

View File

@ -1,4 +1,5 @@
import { Sequelize } from "sequelize";
import { closeSequelize, createSequelize } from "@erp/core/api";
import type { Sequelize } from "sequelize";
import { logger } from "../lib/logger";
@ -9,92 +10,12 @@ import type { ConfigType } from "./index";
* autentica la conexión y devuelve la instancia lista para usar.
*/
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
const sequelize = buildSequelize(config);
try {
await sequelize.authenticate();
logger.info("✔️ Database connection established", {
label: "database",
// Info no sensible:
dialect: sequelize.getDialect(),
host: (sequelize.config.host ?? "url") as string,
database: (sequelize.config.database ?? "url") as string,
});
return sequelize;
} catch (error: any) {
logger.error(`❌ Unable to connect to the database: ${error?.message ?? error}`, {
label: "database",
});
// Cerramos por si quedó algo abierto
try {
await sequelize.close();
} catch {
/* noop */
}
throw error;
}
return await createSequelize(config.database, logger);
}
/**
* Cierra la instancia de Sequelize (para integrar con el shutdown).
*/
export async function closeDatabase(sequelize: Sequelize): Promise<void> {
try {
await sequelize.close();
logger.info("Database connection closed", { label: "database" });
} catch (error: any) {
logger.error(`Error while closing database: ${error?.message ?? error}`, {
label: "database",
});
}
}
function buildSequelize(config: ConfigType): Sequelize {
const common = {
logging: config.database.logging
? (msg: any) => logger.debug(String(msg), { label: "sequelize" })
: false,
timezone: config.server.timezone,
pool: {
max: 10,
min: 0,
idle: 10000,
acquire: 30000,
},
dialectOptions: {
timezone: config.server.timezone,
// Para MariaDB/MySQL:
charset: "utf8mb4_unicode_ci",
},
define: {
charset: "utf8mb4",
collate: "utf8mb4_unicode_ci",
},
} as const;
/*if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") {
// URL completa (recomendada p.ej. en Postgres/MariaDB)
return new Sequelize(ENV.DATABASE_URL, common);
}*/
// Parámetros sueltos (asegurar requeridos mínimos)
if (!config.database.dialect) {
throw new Error("DB_DIALECT is required when DATABASE_URL is not provided");
}
if (!(config.database.name && config.database.user)) {
throw new Error("DB_NAME and DB_USER are required when DATABASE_URL is not provided");
}
return new Sequelize(config.database.name, config.database.user, config.database.password, {
host: config.database.host,
port: config.database.port,
dialect: config.database.dialect,
...common,
});
await closeSequelize(sequelize, logger);
}

View File

@ -3,7 +3,7 @@ import { z } from "zod";
const NodeEnvSchema = z.enum(["development", "test", "production"]);
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
const DbDialectSchema = z.enum(["postgres", "mysql", "mariadb", "mssql", "sqlite"]);
const DbDialectSchema = z.enum(["mysql", "mariadb"]);
export const CompanyCertificateSchema = z.object({
certificateId: z.string().min(1),

View File

@ -3,32 +3,30 @@ import os from "node:os";
import {
AsyncLocalStorageOperationContextStore,
OperationAuditContextProvider,
SequelizeTransactionContext,
} from "@erp/core/api";
import { Audit } from "@repo/rdx-audit";
import { DateTime } from "luxon";
import { z } from "zod/v4";
import { createApp } from "./app.ts";
import { initConfig } from "./config";
import { tryConnectToDatabase } from "./config/database.ts";
import { registerHealthRoutes } from "./health.ts";
import { registerAuditRoutes } from "./infrastructure/audit/audit.routes";
import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader";
import {
AuditChangeModel,
AuditEventModel,
OperationAuditContextProvider,
SequelizeAuditStore,
SequelizeTransactionContext,
auditModelInitializers,
} from "./infrastructure/audit/sequelize";
import { listRoutes, logger } from "./lib/index.ts";
import {
getServiceScoped,
initModules,
listServices,
registerModels,
} from "./lib/modules/index.ts";
startModules,
} from "@erp/core/api";
import { Audit } from "@repo/rdx-audit";
import { DateTime } from "luxon";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { createApp } from "./app.ts";
import { initConfig } from "./config";
import { closeDatabase, tryConnectToDatabase } from "./config/database.ts";
import { registerHealthRoutes } from "./health.ts";
import { registerAuditRoutes } from "./infrastructure/audit/audit.routes";
import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader";
import { listRoutes, logger } from "./lib/index.ts";
import { registerModules } from "./register-modules.ts";
z.config(z.locales.es());
@ -56,6 +54,7 @@ let isShuttingDown = false;
let shutdownPromise: Promise<void> | null = null;
// 🔒 Bandera de readiness (se actualiza durante arranque y apagado)
let isReady = false;
let database: Sequelize | null = null;
// ─────────────────────────────────────────────────────────────────────────────
// Manejo de cierre forzado del servidor (graceful shutdown + sockets)
@ -166,6 +165,7 @@ const handleSignal = (signal: NodeJS.Signals) => async () => {
logger.warn(`Received ${signal}. Starting graceful shutdown...`);
try {
await serverStop(server);
if (database) await closeDatabase(database);
process.exit(0);
} catch (err) {
logger.error("Error during shutdown, forcing exit(1)", { err, label: "handleSignal" });
@ -202,6 +202,7 @@ process.on("unhandledRejection", async (reason: any, promise: Promise<any>) => {
isReady = false;
try {
await serverStop(server);
if (database) await closeDatabase(database);
} finally {
process.exit(1);
}
@ -218,6 +219,7 @@ process.on("uncaughtException", async (error: Error) => {
isReady = false;
try {
await serverStop(server);
if (database) await closeDatabase(database);
} finally {
process.exit(1);
}
@ -233,7 +235,7 @@ process.on("uncaughtException", async (error: Error) => {
logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
logger.info(`Process PID: ${process.pid}`);
const database = await tryConnectToDatabase(config);
database = await tryConnectToDatabase(config);
// Lógica de inicialización de DB, si procede:
// initStructure(sequelizeConn.connection);
@ -241,7 +243,7 @@ process.on("uncaughtException", async (error: Error) => {
// ➕ Rutas de salud disponibles desde el inicio del proceso
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
registerModels(auditModelInitializers, "server-infrastructure");
const auditStore = new SequelizeAuditStore(
AuditEventModel,
AuditChangeModel,
@ -254,6 +256,20 @@ process.on("uncaughtException", async (error: Error) => {
});
await initModules({
config,
app,
database,
logger,
operationContext,
transactionContext,
audit,
warmup: config.warmup,
databaseSyncMode: config.database.syncMode,
getService: () => {
throw new Error("Services are only available from a module-scoped context.");
},
});
await startModules({
config,
app,
database,

View File

@ -1,9 +1,8 @@
import { AuditChangeModel, AuditEventModel } from "@erp/core/api";
import type { AuditValue } from "@repo/rdx-audit";
import type { Criteria, FiltersPrimitives } from "@repo/rdx-criteria/server";
import { Op, Sequelize } from "sequelize";
import { AuditChangeModel, AuditEventModel } from "./sequelize";
export type AuditReadAccess =
| { readonly mode: "unrestricted" }
| {

View File

@ -1,10 +1,8 @@
export * from "./audit-change.model";
export * from "./audit-event.model";
export * from "./sequelize-audit-entry.mapper";
export * from "./sequelize-audit-store";
import { initializeAuditChangeModel } from "./audit-change.model";
import { initializeAuditEventModel } from "./audit-event.model";
/** Initializers globales, registrados una vez por el runtime antes de initModels(). */
export const auditModelInitializers = [initializeAuditEventModel, initializeAuditChangeModel];
// Compatibilidad temporal para rutas HTTP y tests del servidor.
// La persistencia reutilizable reside en @erp/core/api.
export {
AuditChangeModel,
AuditEventModel,
SequelizeAuditStore,
auditModelInitializers,
} from "@erp/core/api";

View File

@ -1,5 +1,5 @@
import type { ISequelizeTransactionContext } from "@erp/core/api";
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
import { AuditStoreError } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import type { ModelStatic } from "sequelize";
@ -19,8 +19,8 @@ export class SequelizeAuditStore implements IAuditStore {
private readonly transactionContext: ISequelizeTransactionContext
) {}
async append(entry: AuditEntry): Promise<Result<void, AuditStoreError>> {
const transaction = this.transactionContext.current();
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
const transaction = options?.transaction ?? this.transactionContext.current();
if (entry.kind === "mutation" && !transaction) {
return Result.fail(
new AuditStoreError("An active transaction is required for audit mutations.")

View File

@ -1,6 +1,6 @@
import { configureLogger, setLoggerSingleton } from "@repo/rdx-logger";
const logger = configureLogger("console"); // o según config/env
const logger = configureLogger("console", { service: "server" });
setLoggerSingleton(logger);
export { logger };

View File

@ -1,216 +1,2 @@
// apps/server/src/lib/modules/model-loader.ts
import type { ModuleParams } from "@erp/core/api";
import type { Sequelize } from "sequelize";
import { logger } from "../logger";
type SequelizeLike = {
sync: (options?: any) => Promise<void>;
models: Record<string, unknown>;
};
type ModelStatic = {
name: string;
associate?: (sequelize: Sequelize) => void;
};
export type ModelInitializer = (sequelize: Sequelize) => ModelStatic;
type InitializerEntry = { init: ModelInitializer; moduleName: string };
type RegisteredModelEntry = {
model: ModelStatic;
moduleName: string;
};
const allModelInitializers: InitializerEntry[] = [];
const registeredModels: Map<string, RegisteredModelEntry> = new Map();
let modelsInitialized = false;
export const registerModels = (
models: ModelInitializer[],
_params?: ModuleParams,
ctx?: { moduleName: string }
) => {
const moduleName = ctx?.moduleName ?? "unknown";
if (!Array.isArray(models) || models.length === 0) {
logger.warn(`No models provided by module "${moduleName}"`, { label: "registerModels" });
return;
}
for (const init of models) {
allModelInitializers.push({ init, moduleName });
}
logger.info(`${models.length} model initializer(s) enqueued from "${moduleName}"`, {
label: "registerModels",
});
};
export const initModels = async (params: ModuleParams) => {
if (modelsInitialized) {
logger.warn("Models already initialized. Skipping initModels()", { label: "initModels" });
return;
}
logger.info("Init models...", { label: "initModels" });
const database = getDatabaseOrThrow(params);
try {
defineRegisteredModels(database);
associateRegisteredModels(database);
await syncDatabase(database, getDatabaseSyncMode(params));
modelsInitialized = true;
} catch (error) {
registeredModels.clear();
throw error;
}
};
const getDatabaseOrThrow = (params: ModuleParams): SequelizeLike => {
const { database } = params as { database?: SequelizeLike };
if (!database) {
const error = new Error("Database not found.");
logger.error(error.message, { label: "initModels" });
throw error;
}
return database;
};
const defineRegisteredModels = (database: SequelizeLike): void => {
for (const entry of allModelInitializers) {
defineModel(database, entry);
}
};
const defineModel = (database: SequelizeLike, entry: InitializerEntry): void => {
const { init, moduleName } = entry;
try {
const model = init(database as unknown as Sequelize);
assertValidModel(model);
assertModelNameIsAvailable(model.name, moduleName);
registeredModels.set(model.name, { model, moduleName });
logger.info(`Model "${model.name}" registered (sequelize)`, {
label: "registerModel",
module: moduleName,
});
} catch (err) {
const error = err as Error;
logger.error(`Failed to define model (module="${moduleName}") : ${error.message}`, {
label: "initModels",
stack: error.stack,
});
throw error;
}
};
const assertValidModel = (model: ModelStatic): void => {
if (!model || typeof model.name !== "string" || !model.name) {
throw new Error('Invalid model initializer: missing or empty "name"');
}
};
const assertModelNameIsAvailable = (modelName: string, moduleName: string): void => {
const existing = registeredModels.get(modelName);
if (!existing) {
return;
}
throw new Error(
`Model name collision: "${modelName}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"`
);
};
const associateRegisteredModels = (database: SequelizeLike): void => {
for (const entry of registeredModels.values()) {
associateModel(database, entry);
}
};
const associateModel = (database: SequelizeLike, entry: RegisteredModelEntry): void => {
const { model, moduleName } = entry;
if (typeof model.associate !== "function") {
return;
}
try {
model.associate(database as unknown as Sequelize);
} catch (err) {
const error = err as Error;
logger.error(
`Failed to associate model "${model.name}" (module="${moduleName}") : ${error.message}`,
{ label: "initModels", stack: error.stack }
);
throw error;
}
};
const getDatabaseSyncMode = (params: ModuleParams): string => {
return params.config.database.syncMode;
};
const syncDatabase = async (database: SequelizeLike, syncMode: string): Promise<void> => {
logger.info(`Database sync mode ${syncMode}`, { label: "initModels" });
try {
if (syncMode === "none") {
logger.info("Database sync skipped (mode=none)", { label: "initModels" });
return;
}
if (syncMode === "alter") {
await database.sync({ force: false, alter: true });
logger.info("Database synchronized successfully (mode=alter).", { label: "initModels" });
return;
}
if (syncMode === "force") {
await database.sync({ force: true, alter: false });
logger.warn("Database synchronized with FORCE (mode=force).", { label: "initModels" });
return;
}
logger.warn(`Unknown DB sync mode "${String(syncMode)}" → skipping`, {
label: "initModels",
});
} catch (err) {
const error = err as Error;
logger.error("Error synchronizing database:", {
error,
label: "initModels",
});
throw error;
}
};
export const getRegisteredModels = () =>
Array.from(registeredModels.entries()).map(([name, { moduleName }]) => ({
name,
moduleName,
}));
export const resetModelsRegistry = () => {
allModelInitializers.length = 0;
registeredModels.clear();
modelsInitialized = false;
logger.info("Model registry has been reset.", { label: "initModels" });
};
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
export { registerModels, resetModuleRuntimeForTests as resetModelsRegistry } from "@erp/core/api";

View File

@ -11,7 +11,7 @@ import { Audit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import { resetModelsRegistry } from "./model-loader";
import { initModules, registerModule, resetModuleRegistry } from "./module-loader";
import { initModules, registerModule, resetModuleRegistry, startModules } from "./module-loader";
import { listServices, resetServiceRegistry } from "./service-registry";
type FakeDatabase = {
@ -33,6 +33,8 @@ function buildModuleParams(): ModuleParams & {
syncMode: "none",
},
},
warmup: { timeoutMs: 10_000, strict: false },
databaseSyncMode: "none",
database: {
sync: async () => undefined,
models: {},
@ -86,6 +88,7 @@ test("module-loader permite dependencias externas declaradas", async (t) => {
registerModule(companiesModule);
await initModules(buildModuleParams());
await startModules(buildModuleParams());
assert.equal(receivedService, "catalogs-service");
assert.deepEqual(listServices().sort(), ["catalogs:general"]);
@ -116,7 +119,10 @@ test("module-loader falla cuando una dependencia usada no está declarada", asyn
});
await assert.rejects(
() => initModules(buildModuleParams()),
async () => {
await initModules(buildModuleParams());
await startModules(buildModuleParams());
},
/without declaring dependency on "catalogs"/i
);
});
@ -160,7 +166,10 @@ test("module-loader rechaza la autoreferencia explícita por nombre de módulo",
});
await assert.rejects(
() => initModules(buildModuleParams()),
async () => {
await initModules(buildModuleParams());
await startModules(buildModuleParams());
},
/without declaring dependency on "companies"/i
);
});

View File

@ -1,374 +1,7 @@
import type {
IModuleServer,
ModuleParams,
ModuleSetupResult,
SetupParams,
StartParams,
WarmupParams,
/** Compatibilidad para imports internos históricos; el runtime vive en @erp/core/api. */
export {
initModules,
registerModule,
resetModuleRuntimeForTests as resetModuleRegistry,
startModules,
} from "@erp/core/api";
import { logger } from "../logger";
import { initModels, registerModels } from "./model-loader";
import { getServiceScoped, listServices, registerService } from "./service-registry";
const registeredModules: Map<string, IModuleServer> = new Map();
// Nuevo: control por fases
const setupDone = new Set<string>(); // para detección de ciclos
const visiting = new Set<string>();
const setupOrder: string[] = [];
// Internal store
const internalByModule: Map<string, Record<string, unknown>> = new Map();
// Tracking de dependencias realmente usadas por módulo
const usedDependenciesByModule: Map<string, Set<string>> = new Map();
// Warmup config (valores por defecto seguros)
const WARMUP_TIMEOUT_MS = Number(process.env.WARMUP_TIMEOUT_MS) || 10_000;
const WARMUP_STRICT =
String(process.env.WARMUP_STRICT ?? "false")
.toLowerCase()
.trim() === "true";
/**
- Registra un módulo del servidor en el registry.
- Lanza error si el nombre ya existe.
*/
export function registerModule(pkg: IModuleServer) {
if (!pkg?.name) throw new Error('❌ Invalid module: missing "name" property');
if (registeredModules.has(pkg.name))
throw new Error(`❌ Module "${pkg.name}" already registered`);
registeredModules.set(pkg.name, pkg);
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
}
/**
* Reset del estado global del loader para entornos de test.
*
* En producción el proceso bootstrappea una sola vez. En tests HTTP necesitamos
* poder recrear app + módulos + servicios varias veces dentro del mismo proceso.
*/
export function resetModuleRegistry(): void {
registeredModules.clear();
setupDone.clear();
visiting.clear();
setupOrder.length = 0;
internalByModule.clear();
usedDependenciesByModule.clear();
}
/**
- Inicializa todos los módulos registrados (resolviendo dependencias),
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
*/
export async function initModules(params: ModuleParams) {
// 1) SETUP
for (const name of registeredModules.keys()) {
await setupModule(name, params, []); // secuencial para logs deterministas
}
// 2) MODELS global
await withPhase("global", "initModels", async () => await initModels(params));
// 3) START por orden de setup
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg?.start) continue;
// Si aún hay rastros del anterior init/registerDependencies en algún módulo:
// - start() ≈ init() en el contrato nuevo
// - setup() ≈ registerDependencies() en el contrato nuevo
await withPhase(name, "start", async () => {
const startParams: StartParams = {
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
};
// Aquí YA se permite getService/getInternal
await Promise.resolve(pkg.start?.(startParams));
});
}
// 4) WARMUP opcional
await warmupModules(params);
// 5) Validación dependencias usadas vs declaradas
validateModuleDependencies();
}
/**
* SETUP recursivo: resuelve deps, ejecuta setup del módulo (antiguo "registerDependencies"),
* registra modelos/servicios/internal, y guarda orden.
*/
async function setupModule(name: string, params: ModuleParams, stack: string[]) {
if (setupDone.has(name)) return;
if (visiting.has(name)) {
throw new Error(`❌ Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
}
const pkg = registeredModules.get(name);
if (!pkg)
throw new Error(
`❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})`
);
visiting.add(name);
stack.push(name);
// 1) deps first
for (const dep of pkg.dependencies ?? []) {
await setupModule(dep, params, stack.slice());
}
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
const pkgApi = await withPhase(name, "setup", async () => {
const setupParams: SetupParams = {
...params,
getService: makeGetService(name, pkg),
listServices,
};
return await Promise.resolve(pkg.setup?.(setupParams));
});
const moduleSetupResult: ModuleSetupResult | undefined = pkgApi;
// 3) internal store
if (moduleSetupResult?.internal) {
internalByModule.set(name, moduleSetupResult.internal);
} else {
internalByModule.set(name, {});
}
// 4) models
if (moduleSetupResult?.models) {
const models = moduleSetupResult.models;
await withPhase(name, "registerModels", async () => {
await Promise.resolve(registerModels(models, params, { moduleName: name }));
});
}
// 5) services (namespaced)
if (moduleSetupResult?.services) {
const services = moduleSetupResult.services;
await withPhase(name, "registerServices", async () => {
validateModuleServices(name, services);
for (const [serviceKey, serviceApi] of Object.entries(services)) {
const fullName = buildServiceName(name, serviceKey);
registerService(fullName, serviceApi);
}
});
}
setupDone.add(name);
setupOrder.push(name);
visiting.delete(name);
stack.pop();
logger.info(`✅ Module "${name}" setup done`, { label: "moduleRegistry" });
}
/** getService instrumentado + scoped */
function makeGetService(moduleName: string, pkg: IModuleServer) {
return <T>(serviceName: string): T => {
const [serviceModule] = serviceName.split(":");
// No registrar dependencias para modulos
// que usan sus propios servicios.
if (serviceModule !== "self") {
trackDependencyUse(moduleName, serviceModule);
}
// IMPORTANTE: devolver el valor
return getServiceScoped<T>(moduleName, pkg.dependencies ?? [], serviceName);
};
}
/** getInternal: por defecto solo permite al propio módulo acceder a su internal */
function makeGetInternal(requesterModule: string) {
return <T>(moduleName: string, key?: string): T => {
if (moduleName !== requesterModule) {
throw new Error(
`Module "${requesterModule}" attempted to access internal of "${moduleName}". ` +
"Internal is private to the owning module."
);
}
const internal = internalByModule.get(moduleName) ?? {};
if (!key) return internal as unknown as T;
if (!(key in internal)) {
throw new Error(`Internal key "${key}" not found in module "${moduleName}".`);
}
return internal[key] as T;
};
}
function trackDependencyUse(requester: string, dep: string) {
let set = usedDependenciesByModule.get(requester);
if (!set) {
set = new Set();
usedDependenciesByModule.set(requester, set);
}
set.add(dep);
}
function buildServiceName(moduleName: string, serviceKey: string): string {
return `${moduleName}:${serviceKey}`;
}
function validateModuleServices(moduleName: string, services: Record<string, unknown>) {
for (const [serviceKey, serviceApi] of Object.entries(services)) {
if (!serviceKey || typeof serviceKey !== "string") {
throw new Error(`Invalid service key from module "${moduleName}"`);
}
const fullName = `${moduleName}:${serviceKey}`;
if (serviceApi === undefined) {
throw new Error(`Service "${fullName}" is undefined (validateModuleServices)`);
}
}
}
function validateModuleDependencies() {
for (const [moduleName, pkg] of registeredModules.entries()) {
const declared = new Set(pkg.dependencies ?? []);
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
// ❌ usadas pero no declaradas
const undeclaredUsed = [...used].filter((d) => !declared.has(d));
if (undeclaredUsed.length > 0) {
throw new Error(
`❌ Module "${moduleName}" used undeclared dependencies: ${undeclaredUsed.join(", ")}`
);
}
// ⚠️ declaradas pero no usadas (opcional)
const unusedDeclared = [...declared].filter((d) => !used.has(d));
if (unusedDeclared.length > 0) {
logger.warn(
`⚠️ Module "${moduleName}" declared unused dependencies: ${unusedDeclared.join(", ")}`,
{ label: "moduleRegistry", module: moduleName }
);
}
}
}
/**
- Ejecuta warmup() opcional de cada módulo en orden de inicialización.
- Si WARMUP_STRICT=true, un fallo aborta el arranque; si no, se avisa y continúa.
*/
async function warmupModules(params: ModuleParams) {
logger.info("🌡️ Warmup: starting...", { label: "moduleRegistry" });
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg?.warmup) continue;
const maybeWarmup = pkg.warmup;
if (typeof maybeWarmup !== "function") continue;
try {
const warmupParams: WarmupParams = {
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
};
await withPhase(name, "warmup", () =>
withTimeout(Promise.resolve(maybeWarmup(warmupParams)), WARMUP_TIMEOUT_MS, `${name}.warmup`)
);
} catch (error) {
if (WARMUP_STRICT) {
logger.error("⛔️ Warmup failed (strict=true). Aborting.", {
label: "moduleRegistry",
module: name,
error,
});
throw error;
}
logger.warn("⚠️ Warmup failed but continuing (strict=false)", {
label: "moduleRegistry",
module: name,
error,
});
}
}
logger.info("🌡️ Warmup: done.", { label: "moduleRegistry" });
}
/**
Helper para anotar fase y módulo en logs (inicio/fin/duración) y errores.
*/
async function withPhase<T>(
moduleName: string,
phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup",
fn: () => Promise<T> | T
): Promise<T> {
const startedAt = Date.now();
logger.info(`▶️ phase=start module=${moduleName} ${phase}`, {
label: "moduleRegistry",
module: moduleName,
phase,
});
try {
const out = await fn();
const duration = Date.now() - startedAt;
logger.info(`✅ phase=done module=${moduleName} ${phase} durationMs=${duration}`, {
label: "moduleRegistry",
module: moduleName,
phase,
durationMs: duration,
});
return out;
} catch (err: unknown) {
const duration = Date.now() - startedAt;
const error = err as Error;
logger.error(
`⛔️ Module "${moduleName}" failed at phase="${phase}" after ${duration}ms: ${error?.message ?? error}`,
{
label: "moduleRegistry",
module: moduleName,
phase,
durationMs: duration,
stack: error?.stack,
}
);
// Re-lanzamos con contexto preservado
const newError = new Error(
`[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}`
);
newError.cause = error;
throw newError;
}
}
/**
Promesa con timeout; rechaza si excede el tiempo dado.
*/
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | null = null;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout after ${ms}ms (${label})`));
}, ms).unref();
});
return Promise.race([p, timeout]).finally(() => {
if (timer) clearTimeout(timer);
}) as Promise<T>;
}

View File

@ -1,81 +1,6 @@
const services: Record<string, unknown> = {};
/**
* Registra un objeto de servicio (API) bajo un nombre.
*/
export function registerService(name: string, api: unknown) {
console.debug(`Registering service: ${name}`);
if (services[name]) {
throw new Error(`❌ Servicio "${name}" ya fue registrado.`);
}
services[name] = api;
}
/**
* Recupera un servicio registrado bajo un "scope".
*
* getService("customers:repository")
* Debe declarar: dependencies: ["customers"]
*
* El "scope" puede ser "self" para recuperar
* los servicios propios registrados.
*
* getService("self:repository")
*/
export function getServiceScoped<T = unknown>(
requesterModule: string,
allowedDeps: readonly string[],
name: string
): T {
const [serviceModule, ...key] = name.split(":");
if (serviceModule === "self") {
return getService<T>(`${requesterModule}:${key.join(":")}`);
}
if (!allowedDeps.includes(serviceModule)) {
throw new Error(
`❌ Module "${requesterModule}" tried to access service "${name}" ` +
`without declaring dependency on "${serviceModule}" (web/manifest.ts api/index.ts file)`
);
}
return getService<T>(name);
}
/**
* Recupera un servicio registrado, con tipado opcional.
*/
function getService<T = unknown>(name: string): T {
const service = services[name];
if (!service) {
throw new Error(`❌ Service "${name}" not found.`);
}
return service as T;
}
/**
* Permite saber si un servicio fue registrado.
*/
export function hasService(name: string): boolean {
return !!services[name];
}
/**
* Devuelve todos los servicios (para depuración o tests).
*/
export function listServices(): string[] {
return Object.keys(services);
}
/**
* Limpia el registry entre arranques de test.
*
* El runtime productivo arranca una sola vez, pero los tests HTTP necesitan
* reinicializar módulos y servicios sin contaminar el siguiente caso.
*/
export function resetServiceRegistry(): void {
for (const name of Object.keys(services)) {
delete services[name];
}
}
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
export {
getServiceScoped,
listServices,
resetModuleRuntimeForTests as resetServiceRegistry,
} from "@erp/core/api";

View File

@ -1,5 +1,6 @@
import catalogsAPIModule from "@erp/catalogs/api";
import companiesAPIModule from "@erp/companies/api";
import { registerModule } from "@erp/core/api";
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import documentSeriesAPIModule from "@erp/document-series/api";
@ -7,8 +8,6 @@ import factuGESAPIModule from "@erp/factuges/api";
import identityAPIModule from "@erp/identity/api";
import suppliersAPIModule from "@erp/suppliers/api";
import { registerModule } from "./lib";
// Aquí hay que registrar los módulos que
// queramos que se carguen en el servidor.
// El registro hace que estén disponibles

View File

@ -10,6 +10,13 @@ import {
type ISequelizeTransactionContext,
OperationAuditContextProvider,
SequelizeTransactionContext,
getServiceScoped,
initModules,
listServices,
registerModels,
registerModule,
resetModuleRuntimeForTests,
startModules,
} from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import { Audit } from "@repo/rdx-audit";
@ -32,16 +39,6 @@ import {
auditModelInitializers,
} from "@/infrastructure/audit/sequelize";
import { logger } from "@/lib/logger.ts";
import {
getServiceScoped,
initModules,
listServices,
registerModels,
registerModule,
resetModelsRegistry,
resetModuleRegistry,
resetServiceRegistry,
} from "@/lib/modules";
import { registerModules } from "@/register-modules.ts";
const currentDir = dirname(fileURLToPath(import.meta.url));
@ -114,8 +111,22 @@ export async function createHttpTestRuntime(
for (const testModule of options.testModules ?? []) {
registerModule(testModule);
}
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
registerModels(auditModelInitializers, "server-infrastructure");
await initModules({
app,
config,
database,
logger,
operationContext,
transactionContext,
audit,
warmup: config.warmup,
databaseSyncMode: config.database.syncMode,
getService: () => {
throw new Error("Services are only available from a module-scoped context.");
},
});
await startModules({
app,
config,
database,
@ -207,9 +218,7 @@ function loadTestEnvironment(): void {
function resetServerRuntimeForTests(): void {
resetConfigForTests();
resetServiceRegistry();
resetModuleRegistry();
resetModelsRegistry();
resetModuleRuntimeForTests();
}
function assignUniqueTestDatabaseName(): void {

View File

@ -58,6 +58,7 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
},
],
},
{ title: "Proformas recurrentes", href: "/recurring-proformas", icon: FileTextIcon },
{ title: "Clientes", href: "/customers", icon: UsersIcon },
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },
],

View File

@ -88,6 +88,9 @@ export const AppTopbar = () => {
<DropdownMenuItem onClick={() => navigate("/customers/create")}>
{t("web.home.actions.newCustomer")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/suppliers/create")}>
{t("web.home.actions.newSupplier")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -56,7 +56,8 @@
"actions": {
"new": "New",
"newProforma": "New proforma",
"newCustomer": "New customer"
"newCustomer": "New customer",
"newSupplier": "New supplier"
},
"kpis": {
"customers": {

View File

@ -56,7 +56,8 @@
"actions": {
"new": "Nuevo",
"newProforma": "Nueva proforma",
"newCustomer": "Nuevo cliente"
"newCustomer": "Nuevo cliente",
"newSupplier": "Nuevo proveedor"
},
"kpis": {
"customers": {

39
apps/worker/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "@erp/factuges-worker",
"version": "0.10.7",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
"start": "NODE_ENV=production node --env-file=.env.production dist/index.js",
"dev": "node --import=tsx --watch src/index.ts",
"clean": "rimraf .turbo node_modules dist",
"test": "node --import tsx --test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint ."
},
"dependencies": {
"@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/companies": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"dotenv": "^17.4.1",
"sequelize": "^6.37.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
"@repo/typescript-config": "workspace:*",
"@types/node": "^25.6.0",
"rimraf": "^6.1.3",
"tsup": "8.5.1",
"tsx": "4.21.0",
"typescript": "^6.0.2"
},
"engines": { "node": ">=22" }
}

View File

@ -0,0 +1,18 @@
import type { DatabaseConfig } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import dotenv from "dotenv";
import { WorkerEnvSchema } from "./worker-env-schema";
export type WorkerConfig = { readonly env: "development" | "test" | "production"; readonly timezone: string; readonly database: DatabaseConfig; readonly jobs: { readonly recurringProformas: { readonly enabled: boolean; readonly intervalMs: number; readonly batchSize: number; readonly maxOccurrencesPerRecurringPerRun: number } } };
export function loadWorkerConfig(logger: ILogger): WorkerConfig {
dotenv.config();
const parsed = WorkerEnvSchema.safeParse(process.env);
if (!parsed.success) {
logger.error("Invalid worker environment configuration", { errors: parsed.error.issues });
throw new Error("Invalid worker environment configuration");
}
const env = parsed.data;
return { env: env.NODE_ENV, timezone: env.TIMEZONE, database: { dialect: env.DB_DIALECT, host: env.DB_HOST, port: env.DB_PORT, name: env.DB_NAME, user: env.DB_USER, password: env.DB_PASS, logging: env.DB_LOGGING, ssl: env.DB_SSL, timezone: env.TIMEZONE }, jobs: { recurringProformas: { enabled: env.RECURRING_PROFORMAS_JOB_ENABLED, intervalMs: env.RECURRING_PROFORMAS_JOB_INTERVAL_MS, batchSize: env.RECURRING_PROFORMAS_BATCH_SIZE, maxOccurrencesPerRecurringPerRun: env.RECURRING_PROFORMAS_MAX_OCCURRENCES_PER_RUN } } };
}

View File

@ -0,0 +1,18 @@
import { z } from "zod";
export const WorkerEnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
TIMEZONE: z.string().min(1).default("Europe/Madrid"),
DB_DIALECT: z.enum(["mysql", "mariadb"]).default("mysql"),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().positive().default(3306),
DB_NAME: z.string().min(1),
DB_USER: z.string().min(1),
DB_PASS: z.string().min(1),
DB_LOGGING: z.coerce.boolean().default(false),
DB_SSL: z.coerce.boolean().default(false),
RECURRING_PROFORMAS_JOB_ENABLED: z.coerce.boolean().default(true),
RECURRING_PROFORMAS_JOB_INTERVAL_MS: z.coerce.number().int().min(5000).default(60000),
RECURRING_PROFORMAS_BATCH_SIZE: z.coerce.number().int().positive().default(50),
RECURRING_PROFORMAS_MAX_OCCURRENCES_PER_RUN: z.coerce.number().int().positive().default(10),
});

44
apps/worker/src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import {
AsyncLocalStorageOperationContextStore,
AuditChangeModel,
AuditEventModel,
OperationAuditContextProvider,
SequelizeAuditStore,
SequelizeTransactionContext,
auditModelInitializers,
closeSequelize,
createSequelize,
initModules,
registerModels,
} from "@erp/core/api";
import { Audit } from "@repo/rdx-audit";
import { configureLogger, setLoggerSingleton } from "@repo/rdx-logger";
import { loadWorkerConfig } from "./config/worker-config";
import { registerWorkerModules } from "./modules/register-modules";
import { Scheduler } from "./scheduler/scheduler";
import { registerJobs } from "./scheduler/register-jobs";
const logger = configureLogger("console", { service: "worker" });
setLoggerSingleton(logger);
const config = loadWorkerConfig(logger);
const operationContext = new AsyncLocalStorageOperationContextStore();
const transactionContext = new SequelizeTransactionContext();
let stopping: Promise<void> | null = null;
async function bootstrap(): Promise<void> {
logger.info("Worker starting");
const database = await createSequelize(config.database, logger);
const audit = new Audit({ source: { type: "application", id: "factuges-worker" }, contextProvider: new OperationAuditContextProvider(operationContext), store: new SequelizeAuditStore(AuditEventModel, AuditChangeModel, transactionContext) });
registerWorkerModules();
registerModels(auditModelInitializers, "worker-infrastructure");
const modulesRuntime = await initModules({ config, database, logger, operationContext, transactionContext, audit, warmup: { timeoutMs: 10_000, strict: false }, databaseSyncMode: "none", getService: () => { throw new Error("Services are available only in module scope."); } });
const scheduler = new Scheduler(registerJobs({ config, logger, runtime: modulesRuntime }), logger);
scheduler.start();
logger.info("Worker started");
const shutdown = async () => { if (stopping) return stopping; stopping = (async () => { logger.info("Worker stopping"); await scheduler.stop(); await closeSequelize(database, logger); logger.info("Worker stopped"); })(); return stopping; };
process.on("SIGINT", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)); });
process.on("SIGTERM", () => { shutdown().then(() => process.exit(0)).catch(() => process.exit(1)); });
}
bootstrap().catch((error) => { logger.error("Worker bootstrap failed", { error }); process.exit(1); });

View File

@ -0,0 +1,44 @@
import type {
RecurringProformaDiscoveryCapability,
RecurringProformaOccurrenceProcessorCapability,
} from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import type { ScheduledJob } from "../scheduler/scheduled-job";
/** Adapta el polling del worker al processor, que conserva todo el negocio P4. */
export class RecurringProformaScheduledJob implements ScheduledJob {
public readonly name = "recurring-proformas";
public constructor(private readonly deps: {
intervalMs: number;
batchSize: number;
maxOccurrencesPerRecurringPerRun: number;
repository: RecurringProformaDiscoveryCapability;
processor: RecurringProformaOccurrenceProcessorCapability;
logger: ILogger;
dueThrough: () => Date;
}) {}
public get intervalMs(): number { return this.deps.intervalMs; }
public async execute(): Promise<void> {
const dueThrough = this.deps.dueThrough();
const discovered = await this.deps.repository.findDue(dueThrough, this.deps.batchSize);
if (discovered.isFailure) throw discovered.error;
this.deps.logger.info("Recurring proformas job started", { dueThrough: dueThrough.toISOString(), discovered: discovered.data.length });
for (const due of discovered.data) {
try {
for (let count = 0; count < this.deps.maxOccurrencesPerRecurringPerRun; count += 1) {
const processed = await this.deps.processor.process({ companyId: due.companyId, recurringProformaId: due.recurringProformaId });
if (!processed.isSuccess) break;
const state = processed.data;
if (state?.recurringProformaStatus === "completed" || state?.nextOccurrenceDate === null || state?.nextOccurrenceDate === undefined || state.nextOccurrenceDate.getTime() > dueThrough.getTime()) break;
}
} catch (error) {
this.deps.logger.error("Recurring proforma job candidate failed", { recurringProformaId: due.recurringProformaId.toPrimitive(), companyId: due.companyId.toPrimitive(), error });
}
}
this.deps.logger.info("Recurring proformas job completed", { dueThrough: dueThrough.toISOString() });
}
}

View File

@ -0,0 +1,17 @@
import { registerModule } from "@erp/core/api";
import { catalogsModule } from "@erp/catalogs/module";
import { companiesModule } from "@erp/companies/module";
import { customerInvoicesModule } from "@erp/customer-invoices/api/module";
import { customersModule } from "@erp/customers/module";
import { documentSeriesModule } from "@erp/document-series/module";
import { identityModule } from "@erp/identity/module";
/** Registra solo el manifest neutral; los adapters HTTP no forman parte del worker. */
export function registerWorkerModules(): void {
registerModule(identityModule);
registerModule(catalogsModule);
registerModule(companiesModule);
registerModule(customersModule);
registerModule(documentSeriesModule);
registerModule(customerInvoicesModule);
}

View File

@ -0,0 +1,28 @@
import type { ModulesRuntime } from "@erp/core/api";
import type { ILogger } from "@repo/rdx-logger";
import type { WorkerConfig } from "../config/worker-config";
import { RecurringProformaScheduledJob } from "../jobs/recurring-proforma-scheduled-job";
import type { ScheduledJob } from "./scheduled-job";
/** SSOT explícita de jobs activos. */
export function registerJobs(params: {
config: WorkerConfig;
logger: ILogger;
runtime: ModulesRuntime;
}): ScheduledJob[] {
if (!params.config.jobs.recurringProformas.enabled) return [];
const recurring = params.runtime.customerInvoices.recurringProformas;
return [new RecurringProformaScheduledJob({
intervalMs: params.config.jobs.recurringProformas.intervalMs,
batchSize: params.config.jobs.recurringProformas.batchSize,
maxOccurrencesPerRecurringPerRun: params.config.jobs.recurringProformas.maxOccurrencesPerRecurringPerRun,
repository: recurring.repository,
processor: recurring.occurrenceProcessor,
logger: params.logger,
dueThrough: () => {
const now = new Date();
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
},
})];
}

View File

@ -0,0 +1 @@
export interface ScheduledJob { readonly name: string; readonly intervalMs: number; execute(): Promise<void>; }

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { configureLogger } from "@repo/rdx-logger";
import { Scheduler } from "./scheduler";
test("Scheduler rechaza nombres duplicados", () => {
assert.throws(
() => new Scheduler([{ name: "same", intervalMs: 1, execute: async () => undefined }, { name: "same", intervalMs: 1, execute: async () => undefined }], configureLogger()),
/Duplicate scheduled job name/
);
});
test("Scheduler ejecuta inmediatamente y espera la ejecución activa al parar", async () => {
let started = false;
let release: (() => void) | undefined;
const scheduler = new Scheduler([{ name: "probe", intervalMs: 1, execute: async () => { started = true; await new Promise<void>((resolve) => { release = resolve; }); } }], configureLogger());
scheduler.start();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(started, true);
const stopping = scheduler.stop();
assert.ok(release);
release();
await stopping;
});

View File

@ -0,0 +1,45 @@
import type { ILogger } from "@repo/rdx-logger";
import type { ScheduledJob } from "./scheduled-job";
export class Scheduler {
private stopping = false;
private readonly loops: Promise<void>[] = [];
private readonly delays = new Set<() => void>();
private idleHandle: NodeJS.Timeout | null = null;
constructor(private readonly jobs: readonly ScheduledJob[], private readonly logger: ILogger) {
const names = new Set<string>();
for (const job of jobs) { if (names.has(job.name)) throw new Error(`Duplicate scheduled job name: ${job.name}`); names.add(job.name); }
}
start(): void {
for (const job of this.jobs) { this.logger.info("Job registered", { job: job.name }); this.loops.push(this.run(job)); }
if (this.jobs.length === 0) this.idleHandle = setInterval(() => undefined, 2_147_483_647);
}
async stop(): Promise<void> {
if (this.stopping) return;
this.stopping = true;
if (this.idleHandle) clearInterval(this.idleHandle);
for (const resolve of this.delays) resolve();
await Promise.all(this.loops);
}
private async run(job: ScheduledJob): Promise<void> {
while (!this.stopping) {
this.logger.debug("Job started", { job: job.name });
try { await job.execute(); this.logger.debug("Job completed", { job: job.name }); }
catch (error) { this.logger.error("Job failed", { job: job.name, error }); }
if (!this.stopping) await this.delay(job.intervalMs);
}
}
private async delay(intervalMs: number): Promise<void> {
await new Promise<void>((resolve) => {
let timeout: NodeJS.Timeout;
const cancel = () => { clearTimeout(timeout); this.delays.delete(cancel); resolve(); };
timeout = setTimeout(cancel, intervalMs);
this.delays.add(cancel);
});
}
}

View File

@ -0,0 +1,6 @@
{
"extends": "@repo/typescript-config/express.json",
"compilerOptions": { "baseUrl": ".", "noEmit": true, "allowImportingTsExtensions": true, "rootDir": "./src", "outDir": "./dist", "types": ["node"] },
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,3 @@
import { defineConfig } from "tsup";
export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], target: "node22", bundle: true, clean: true, outDir: "dist", platform: "node", noExternal: ["@repo", "@erp"] });

View File

@ -33,7 +33,8 @@
"lineEnding": "lf",
"attributePosition": "auto",
"bracketSpacing": true,
"bracketSameLine": true
"bracketSameLine": true,
"expand": "auto"
},
"linter": {
"enabled": true,

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/manifest.ts",
"./client/*": "./src/web/*/index.ts"
},
@ -53,4 +54,4 @@
"use-debounce": "^10.1.1",
"zod": "^4.3.6"
}
}
}

View File

@ -7,12 +7,14 @@ export interface FindActiveTaxDefinitionByCodeParams {
companyId: UniqueID;
code: string;
atDate: UtcDate;
transaction?: unknown;
}
export interface FindActiveTaxDefinitionsByCodesParams {
companyId: UniqueID;
codes: Collection<string>;
atDate: UtcDate;
transaction?: unknown;
}
export interface ITaxDefinitionPublicFinder {

View File

@ -1,101 +1,23 @@
import type { IModuleServer } from "@erp/core/api";
import { paymentMethodsRouter } from "./infrastructure/payment-methods/express";
import { paymentTermsRouter } from "./infrastructure/payment-terms/express";
import { taxDefinitionsRouter } from "./infrastructure/tax-definitions/express";
import { taxRegimesRouter } from "./infrastructure/tax-regimes/express";
import { catalogsModule } from "./module";
import {
paymentMethodModels,
paymentMethodsRouter,
paymentTermModels,
paymentTermsRouter,
taxDefinitionModels,
taxDefinitionsRouter,
taxRegimeModels,
taxRegimesRouter,
} from "./infrastructure";
import {
buildCatalogsDependencies,
buildCatalogsPublicServices,
} from "./infrastructure/di/catalogs.di";
export * from "./module";
export * from "./application/payment-methods/public";
export * from "./application/payment-terms/public";
export * from "./application/tax-definitions/public";
export * from "./application/tax-regimes/public";
//export * from "./infrastructure/payment-methods/persistence/sequelize"; <- ???
export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicServices>;
export const catalogsAPIModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
dependencies: ["identity"],
/**
* Fase de SETUP
* ----------------
* - Construye el dominio (una sola vez)
* - Define qué expone el módulo
* - NO conecta infraestructura
*/
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
// 1) Dominio interno
const internal = buildCatalogsDependencies(params);
// 2) Servicios públicos (Application Services)
const publicServices = buildCatalogsPublicServices(params, internal);
logger.info("🚀 Catalogs module dependencies registered", {
label: this.name,
});
return {
// Modelos Sequelize del módulo
models: [
...paymentMethodModels,
...paymentTermModels,
...taxDefinitionModels,
...taxRegimeModels,
],
// Servicios expuestos a otros módulos
services: {
...publicServices,
},
// Implementación privada del módulo
internal,
};
},
/**
* Fase de START
* -------------
* - Conecta el módulo al runtime
* - Puede usar servicios e internals ya construidos
* - NO construye dominio
*/
async start(params) {
const { logger } = params;
// Registro de rutas HTTP
/** Integración HTTP sobre el manifest neutral, sin reconstruir dependencias. */
export const catalogsAPIModule = {
...catalogsModule,
async start(params: StartParams) {
paymentMethodsRouter(params);
paymentTermsRouter(params);
taxRegimesRouter(params);
taxDefinitionsRouter(params);
logger.info("🚀 Catalogs module started", {
label: this.name,
});
params.logger.info("Catalogs module started", { label: catalogsModule.name });
},
/**
* Warmup opcional (si lo necesitas en el futuro)
* ----------------------------------------------
* warmup(params) {
* ...
* }
*/
};
export default catalogsAPIModule;
import type { StartParams } from "@erp/core/api";

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { catalogsModule } from "./module";
test("catalogsModule compone el finder público de payment methods sin HTTP", async () => {
const setup = await catalogsModule.setup?.({
database: {},
transactionContext: {},
audit: { recordMutation: async () => ({ isFailure: false }) },
getService: () => ({}),
logger: { info: () => undefined },
} as never);
const paymentMethods = setup?.services?.paymentMethods as { finder: { constructor: { name: string } } };
assert.equal(paymentMethods.finder.constructor.name, "PaymentMethodPublicFinder");
});

View File

@ -0,0 +1,38 @@
import type { IModuleServer } from "@erp/core/api";
import {
buildCatalogsDependencies,
buildCatalogsPublicServices,
} from "./infrastructure/di/catalogs.di";
import { paymentMethodModels } from "./infrastructure/payment-methods/persistence/sequelize";
import { paymentTermModels } from "./infrastructure/payment-terms/persistence/sequelize";
import { taxDefinitionModels } from "./infrastructure/tax-definitions/persistence/sequelize";
import { taxRegimeModels } from "./infrastructure/tax-regimes/persistence/sequelize";
export * from "./application/payment-methods/public";
export * from "./application/payment-terms/public";
export * from "./application/tax-definitions/public";
export * from "./application/tax-regimes/public";
export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicServices>;
/** Manifest neutral de catálogos: composición y servicios públicos sin HTTP. */
export const catalogsModule: IModuleServer = {
name: "catalogs",
version: "1.0.0",
dependencies: ["identity"],
async setup(params) {
const internal = buildCatalogsDependencies(params);
const publicServices = buildCatalogsPublicServices(params, internal);
return {
models: [
...paymentMethodModels,
...paymentTermModels,
...taxDefinitionModels,
...taxRegimeModels,
],
services: publicServices,
internal,
};
},
};

View File

@ -15,6 +15,7 @@
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./module": "./src/api/module.ts",
"./client": "./src/web/manifest.ts"
},
"peerDependencies": {
@ -50,4 +51,4 @@
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

View File

@ -1,78 +1,17 @@
import type { IModuleServer } from "@erp/core/api";
import type { StartParams } from "@erp/core/api";
import type { ICompanyPublicServices } from "./application";
import { companiesRouter, models } from "./infrastructure";
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
import { companiesRouter } from "./infrastructure/express";
import { companiesModule } from "./module";
export type { ICompanyPublicServices } from "./application";
export * from "./infrastructure/persistence/sequelize";
export * from "./module";
export type CompaniesPublicServicesType = ICompanyPublicServices;
export const companiesAPIModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["identity", "catalogs"],
/**
* Fase de SETUP
* ----------------
* - Construye el dominio (una sola vez)
* - Define qué expone el módulo
* - NO conecta infraestructura
*/
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
const internal = buildCompaniesDependencies(params);
const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices(
params,
internal
);
logger.info("🚀 Companies module dependencies registered", {
label: this.name,
});
return {
// Modelos Sequelize del módulo
models,
// Servicios expuestos a otros módulos
services: {
general: companiesServices, // 'companies:general'
},
// Implementación privada del módulo
internal,
};
},
/**
* Fase de START
* -------------
* - Conecta el módulo al runtime
* - Puede usar servicios e internals ya construidos
* - NO construye dominio
*/
async start(params) {
const { app, baseRoutePath, logger, getInternal } = params;
// Registro de rutas HTTP
/** Integración HTTP sobre el manifest neutral de compañías. */
export const companiesAPIModule = {
...companiesModule,
async start(params: StartParams) {
companiesRouter(params);
logger.info("🚀 Companies module started", {
label: this.name,
});
params.logger.info("Companies module started", { label: companiesModule.name });
},
/**
* Warmup opcional (si lo necesitas en el futuro)
* ----------------------------------------------
* warmup(params) {
* ...
* }
*/
};
export default companiesAPIModule;

View File

@ -1,5 +1,5 @@
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
import type { IIdentityPublicServices } from "@erp/identity/api";
import type { IIdentityCompanyAccessChecker } from "@erp/identity/module";
import type { Sequelize } from "sequelize";
import {
@ -44,7 +44,9 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
const { audit, database, getService, transactionContext } = params;
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
const responseMapper = new CompanyPublicModelMapper();
const identityServices = getService<IIdentityPublicServices>("identity:general");
const companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
"identity:general"
).companyAccess;
const catalogsDeps = resolveCompanyCatalogsDeps(params);
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
@ -84,7 +86,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
}),
findAvailableCompaniesForAccount: () =>
new FindAvailableCompaniesForAccountUseCase({
identityServices,
identityServices: { companyAccess },
repository,
responseMapper,
transactionManager,

View File

@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { companiesModule } from "./module";
test("companiesModule compone el finder público de compañías sin HTTP", async () => {
const setup = await companiesModule.setup?.({
database: {},
transactionContext: {},
audit: { recordMutation: async () => ({ isFailure: false }) },
getService: () => ({ finder: {} }),
logger: { info: () => undefined },
} as never);
const general = setup?.services?.general as { finder: unknown };
assert.equal(typeof general.finder, "object");
});

View File

@ -0,0 +1,22 @@
import type { IModuleServer } from "@erp/core/api";
import type { ICompanyPublicServices } from "./application";
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize";
export type { ICompanyPublicServices } from "./application";
export * from "./infrastructure/persistence/sequelize";
export type CompaniesPublicServicesType = ICompanyPublicServices;
/** Manifest neutral de compañías: capacidades públicas sin HTTP. */
export const companiesModule: IModuleServer = {
name: "companies",
version: "1.0.0",
dependencies: ["identity", "catalogs"],
async setup(params) {
const internal = buildCompaniesDependencies(params);
const general: ICompanyPublicServices = buildCompaniesPublicServices(params, internal);
return { models, services: { general }, internal };
},
};

View File

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

View File

@ -0,0 +1,12 @@
import { DataTypes, type InferAttributes, type InferCreationAttributes, Model, type Sequelize } from "sequelize";
import { AuditEventModel } from "./audit-event.model";
export class AuditChangeModel extends Model<InferAttributes<AuditChangeModel>, InferCreationAttributes<AuditChangeModel>> {
declare audit_event_id: string; declare position: number; declare path: string; declare kind: string; declare before_value: unknown | null; declare after_value: unknown | null;
static associate(_database: Sequelize): void { AuditChangeModel.belongsTo(AuditEventModel, { as: "event", foreignKey: "audit_event_id", targetKey: "id" }); }
}
export function initializeAuditChangeModel(database: Sequelize): typeof AuditChangeModel {
AuditChangeModel.init({ audit_event_id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true, references: { model: "audit_events", key: "id" }, onUpdate: "RESTRICT", onDelete: "CASCADE" }, position: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, primaryKey: true }, path: { type: DataTypes.STRING(255), allowNull: false }, kind: { type: DataTypes.STRING(32), allowNull: false }, before_value: { type: DataTypes.JSON, allowNull: true, defaultValue: null, get(this: AuditChangeModel): unknown | null { return deserialize(this.getDataValue("before_value")); } }, after_value: { type: DataTypes.JSON, allowNull: true, defaultValue: null, get(this: AuditChangeModel): unknown | null { return deserialize(this.getDataValue("after_value")); } } }, { sequelize: database, modelName: "AuditChangeModel", tableName: "audit_changes", charset: "utf8mb4", collate: "utf8mb4_unicode_ci", underscored: true, timestamps: false });
return AuditChangeModel;
}
function deserialize(value: unknown): unknown | null { return typeof value === "string" ? JSON.parse(value) : value; }

View File

@ -0,0 +1,24 @@
import { DataTypes, type InferAttributes, type InferCreationAttributes, Model, type Sequelize } from "sequelize";
import { AuditChangeModel } from "./audit-change.model";
export class AuditEventModel extends Model<InferAttributes<AuditEventModel>, InferCreationAttributes<AuditEventModel>> {
declare id: string; declare kind: string; declare occurred_at: Date; declare source_type: string; declare source_id: string;
declare operation_id: string; declare correlation_id: string; declare origin: string; declare actor_type: string;
declare actor_id: string | null; declare actor_label: string | null; declare scope_type: string | null; declare scope_id: string | null;
declare scope_label: string | null; declare action: string; declare target_type: string; declare target_id: string | null; declare target_label: string | null;
static associate(_database: Sequelize): void { AuditEventModel.hasMany(AuditChangeModel, { as: "changes", foreignKey: "audit_event_id", sourceKey: "id" }); }
}
export function initializeAuditEventModel(database: Sequelize): typeof AuditEventModel {
AuditEventModel.init({
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true }, kind: { type: DataTypes.STRING(32), allowNull: false }, occurred_at: { type: DataTypes.DATE(3), allowNull: false },
source_type: { type: DataTypes.STRING(64), allowNull: false }, source_id: { type: DataTypes.STRING(128), allowNull: false }, operation_id: { type: DataTypes.STRING(128), allowNull: false },
correlation_id: { type: DataTypes.STRING(128), allowNull: false }, origin: { type: DataTypes.STRING(64), allowNull: false }, actor_type: { type: DataTypes.STRING(64), allowNull: false },
actor_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null }, actor_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
scope_type: { type: DataTypes.STRING(64), allowNull: true, defaultValue: null }, scope_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null },
scope_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null }, action: { type: DataTypes.STRING(191), allowNull: false }, target_type: { type: DataTypes.STRING(64), allowNull: false },
target_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null }, target_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
}, { sequelize: database, modelName: "AuditEventModel", tableName: "audit_events", charset: "utf8mb4", collate: "utf8mb4_unicode_ci", underscored: true, timestamps: false,
indexes: [{ name: "idx_audit_events_occurred_at_id", fields: ["occurred_at", "id"] }, { name: "idx_audit_events_scope_occurred_at_id", fields: ["scope_type", "scope_id", "occurred_at", "id"] }, { name: "idx_audit_events_target_occurred_at_id", fields: ["target_type", "target_id", "occurred_at", "id"] }, { name: "idx_audit_events_actor_occurred_at_id", fields: ["actor_type", "actor_id", "occurred_at", "id"] }, { name: "idx_audit_events_operation_id", fields: ["operation_id"] }, { name: "idx_audit_events_correlation_id", fields: ["correlation_id"] }] });
return AuditEventModel;
}

View File

@ -0,0 +1,7 @@
export * from "./audit-change.model";
export * from "./audit-event.model";
export * from "./sequelize-audit-store";
import { initializeAuditChangeModel } from "./audit-change.model";
import { initializeAuditEventModel } from "./audit-event.model";
export const auditModelInitializers = [initializeAuditEventModel, initializeAuditChangeModel];

View File

@ -0,0 +1,67 @@
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
import { AuditStoreError } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import type { ModelStatic } from "sequelize";
import type { ISequelizeTransactionContext } from "../../persistence/sequelize";
import type { AuditChangeModel } from "./audit-change.model";
import type { AuditEventModel } from "./audit-event.model";
export class SequelizeAuditStore implements IAuditStore {
constructor(
private readonly eventModel: ModelStatic<AuditEventModel>,
private readonly changeModel: ModelStatic<AuditChangeModel>,
private readonly transactionContext: ISequelizeTransactionContext
) {}
async append(
entry: AuditEntry,
options?: AuditRecordOptions
): Promise<Result<void, AuditStoreError>> {
const transaction = options?.transaction ?? this.transactionContext.current();
if (entry.kind === "mutation" && !transaction)
return Result.fail(
new AuditStoreError("An active transaction is required for audit mutations.")
);
try {
await this.eventModel.create(
{
id: entry.id,
kind: entry.kind,
occurred_at: new Date(entry.occurredAt),
source_type: entry.source.type,
source_id: entry.source.id,
operation_id: entry.operation.id,
correlation_id: entry.operation.correlationId,
origin: entry.operation.origin,
actor_type: entry.actor.type,
actor_id: entry.actor.id,
actor_label: entry.actor.label,
scope_type: entry.scope?.type ?? null,
scope_id: entry.scope?.id ?? null,
scope_label: entry.scope?.label ?? null,
action: entry.action,
target_type: entry.target.type,
target_id: entry.target.id,
target_label: entry.target.label,
},
{ transaction: transaction ?? undefined }
);
if (entry.kind === "mutation")
await this.changeModel.bulkCreate(
entry.changes.map((change, position) => ({
audit_event_id: entry.id,
position,
path: change.path,
kind: change.kind,
before_value: change.before,
after_value: change.after,
})),
{ transaction }
);
return Result.ok();
} catch {
return Result.fail(new AuditStoreError("Unable to append audit entry."));
}
}
}

View File

@ -1,4 +1,5 @@
export * from "./config";
export * from "./audit";
export * from "./context";
export * from "./database";
export * from "./di";

View File

@ -0,0 +1,14 @@
export type DatabaseDialect = "mysql" | "mariadb";
/** Configuración necesaria para construir una conexión Sequelize de backend. */
export interface DatabaseConfig {
readonly dialect: DatabaseDialect;
readonly host: string;
readonly port: number;
readonly name: string;
readonly user: string;
readonly password: string;
readonly logging: boolean;
readonly ssl: boolean;
readonly timezone: string;
}

View File

@ -1,4 +1,6 @@
export * from "./mappers";
export * from "./database-config";
export * from "./sequelize-factory";
export * from "./sequelize-error-translator";
export * from "./sequelize-func";
export * from "./sequelize-repository";

View File

@ -0,0 +1,47 @@
import type { ILogger } from "@repo/rdx-logger";
import { Sequelize } from "sequelize";
import type { DatabaseConfig } from "./database-config";
export async function createSequelize(config: DatabaseConfig, logger: ILogger): Promise<Sequelize> {
const database = new Sequelize(config.name, config.user, config.password, {
host: config.host,
port: config.port,
dialect: config.dialect,
logging: config.logging
? (message) => logger.debug(String(message), { label: "sequelize" })
: false,
timezone: config.timezone,
pool: { max: 10, min: 0, idle: 10_000, acquire: 30_000 },
dialectOptions: {
timezone: config.timezone,
charset: "utf8mb4_unicode_ci",
//...(config.ssl ? { ssl: { require: true, rejectUnauthorized: true } } : {}),
},
define: { charset: "utf8mb4", collate: "utf8mb4_unicode_ci" },
});
try {
await database.authenticate();
logger.info("Database connection established", {
label: "database",
dialect: database.getDialect(),
host: database.config.host ?? "url",
database: database.config.database ?? "url",
});
return database;
} catch (error) {
logger.error("Unable to connect to the database", { label: "database", error });
await closeSequelize(database, logger);
throw error;
}
}
export async function closeSequelize(database: Sequelize, logger: ILogger): Promise<void> {
try {
await database.close();
logger.info("Database connection closed", { label: "database" });
} catch (error) {
logger.error("Error while closing database", { label: "database", error });
}
}

View File

@ -1,2 +1,4 @@
export * from "./module-server.interface";
export * from "./module-runtime";
export * from "./modules-runtime";
export * from "./types";

View File

@ -0,0 +1,114 @@
import type { ILogger } from "@repo/rdx-logger";
import type { ModelStatic, Sequelize } from "sequelize";
import type { IModuleServer } from "./module-server.interface";
import type { ModuleParams, ModuleSetupResult, StartParams, WarmupParams } from "./types";
import type { ModulesRuntime } from "./modules-runtime";
type ModelInitializer = (database: Sequelize) => ModelStatic;
type ModelEntry = { initializer: ModelInitializer; moduleName: string };
const modules = new Map<string, IModuleServer>();
const services: Record<string, unknown> = {};
const models: ModelEntry[] = [];
const setupOrder: string[] = [];
const setupDone = new Set<string>();
const visiting = new Set<string>();
const internals = new Map<string, Record<string, unknown>>();
const usedDependencies = new Map<string, Set<string>>();
let modelsInitialized = false;
export function registerModule(module: IModuleServer): void {
if (!module.name) throw new Error('Invalid module: missing "name" property');
if (modules.has(module.name)) throw new Error(`Module "${module.name}" already registered`);
modules.set(module.name, module);
}
export function registerModels(initializers: ModelInitializer[], moduleName: string): void {
for (const initializer of initializers) models.push({ initializer, moduleName });
}
/** Inicializa únicamente infraestructura backend: setup, modelos y warmups. */
export async function initModules(params: ModuleParams & { logger: ILogger; database: Sequelize; warmup: { timeoutMs: number; strict: boolean }; databaseSyncMode: string }): Promise<ModulesRuntime> {
for (const name of modules.keys()) await setupModule(name, params, []);
await initializeModels(params.database, params.databaseSyncMode, params.logger);
await warmupModules(params);
validateDependencies();
const customerInvoices = internals.get("customer-invoices") as { proformas: { recurringProformas: ModulesRuntime["customerInvoices"]["recurringProformas"] } } | undefined;
if (!customerInvoices) throw new Error('Module "customer-invoices" did not expose recurring runtime capabilities');
return { customerInvoices: customerInvoices.proformas.recurringProformas };
}
/** Ejecuta la fase opcional de conexión al runtime, que el server usa para HTTP. */
export async function startModules(params: ModuleParams & { logger: ILogger }): Promise<void> {
for (const name of setupOrder) {
const module = modules.get(name);
if (!module?.start) continue;
await module.start({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as StartParams);
}
validateDependencies();
}
async function setupModule(name: string, params: ModuleParams & { logger: ILogger }, stack: string[]): Promise<void> {
if (setupDone.has(name)) return;
if (visiting.has(name)) throw new Error(`Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
const module = modules.get(name);
if (!module) throw new Error(`Module "${name}" not found`);
visiting.add(name);
for (const dependency of module.dependencies ?? []) await setupModule(dependency, params, [...stack, name]);
const result = (await module.setup?.({ ...params, getService: makeGetService(name, module), listServices })) as ModuleSetupResult | undefined;
internals.set(name, result?.internal ?? {});
if (result?.models) registerModels(result.models as ModelInitializer[], name);
for (const [key, service] of Object.entries(result?.services ?? {})) registerService(`${name}:${key}`, service);
setupDone.add(name);
setupOrder.push(name);
visiting.delete(name);
}
async function initializeModels(database: Sequelize, syncMode: string, logger: ILogger): Promise<void> {
if (modelsInitialized) return;
const registered = new Map<string, { model: ModelStatic; moduleName: string }>();
for (const entry of models) {
const model = entry.initializer(database);
if (!model?.name) throw new Error(`Invalid model initializer from "${entry.moduleName}"`);
if (registered.has(model.name)) throw new Error(`Model name collision: "${model.name}"`);
registered.set(model.name, { model, moduleName: entry.moduleName });
}
for (const { model } of registered.values()) {
const associate = model as ModelStatic & { associate?: (sequelize: Sequelize) => void };
associate.associate?.(database);
}
if (syncMode === "alter") await database.sync({ force: false, alter: true });
if (syncMode === "force") await database.sync({ force: true, alter: false });
logger.info("Backend module models initialized", { label: "moduleRuntime", modelCount: registered.size });
modelsInitialized = true;
}
async function warmupModules(params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }): Promise<void> {
for (const name of setupOrder) {
const module = modules.get(name);
if (!module?.warmup) continue;
try {
await withTimeout(Promise.resolve(module.warmup({ ...params, getService: makeGetService(name, module), getInternal: makeGetInternal(name), listServices } as WarmupParams)), params.warmup.timeoutMs, `${name}.warmup`);
} catch (error) {
if (params.warmup.strict) throw error;
params.logger.warn("Module warmup failed but continuing", { label: "moduleRuntime", module: name, error });
}
}
}
function registerService(name: string, service: unknown): void { if (services[name]) throw new Error(`Service "${name}" already registered`); services[name] = service; }
function makeGetService(moduleName: string, module: IModuleServer) { return <T>(name: string): T => { const [dependency, ...key] = name.split(":"); if (dependency !== "self") { if (!(module.dependencies ?? []).includes(dependency)) throw new Error(`Module "${moduleName}" tried to access service "${name}" without declaring dependency on "${dependency}"`); trackDependency(moduleName, dependency); return getService<T>(name); } return getService<T>(`${moduleName}:${key.join(":")}`); }; }
function makeGetInternal(moduleName: string) { return <T>(requestedModule: string, key?: string): T => { if (requestedModule !== moduleName) throw new Error(`Module "${moduleName}" attempted to access private internal state`); const internal = internals.get(moduleName) ?? {}; return (key ? internal[key] : internal) as T; }; }
function getService<T>(name: string): T { const service = services[name]; if (!service) throw new Error(`Service "${name}" not found`); return service as T; }
function trackDependency(moduleName: string, dependency: string): void { const used = usedDependencies.get(moduleName) ?? new Set<string>(); used.add(dependency); usedDependencies.set(moduleName, used); }
function validateDependencies(): void { for (const [name, module] of modules) { const declared = new Set(module.dependencies ?? []); const used = usedDependencies.get(name) ?? new Set<string>(); const undeclared = [...used].filter((dependency) => !declared.has(dependency)); if (undeclared.length) throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`); } }
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise<T>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)), timeoutMs); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }) as Promise<T>; }
export function listServices(): string[] { return Object.keys(services); }
export function getServiceScoped<T>(requesterModule: string, allowedDependencies: readonly string[], name: string): T {
const [moduleName, ...key] = name.split(":");
if (moduleName === "self") return getService<T>(`${requesterModule}:${key.join(":")}`);
if (!allowedDependencies.includes(moduleName)) throw new Error(`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`);
return getService<T>(name);
}
export function resetModuleRuntimeForTests(): void { modules.clear(); models.length = 0; setupOrder.length = 0; setupDone.clear(); visiting.clear(); internals.clear(); usedDependencies.clear(); for (const key of Object.keys(services)) delete services[key]; modelsInitialized = false; }

View File

@ -0,0 +1,25 @@
import type { UniqueID } from "@repo/rdx-ddd";
export interface RecurringProformaOccurrenceProcessorCapability {
process(params: { recurringProformaId: UniqueID; companyId: UniqueID }): Promise<{
isSuccess: boolean;
data?: { recurringProformaStatus?: string; nextOccurrenceDate?: Date | null };
}>;
}
export interface RecurringProformaDiscoveryCapability {
findDue(dueThrough: Date, limit: number): Promise<{
isFailure: boolean;
error: Error;
data: readonly { recurringProformaId: UniqueID; companyId: UniqueID; nextOccurrenceDate: Date }[];
}>;
}
export interface ModulesRuntime {
customerInvoices: {
recurringProformas: {
occurrenceProcessor: RecurringProformaOccurrenceProcessorCapability;
repository: RecurringProformaDiscoveryCapability;
};
};
}

View File

@ -14,6 +14,8 @@
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./api/application": "./src/api/application/index.ts",
"./api/module": "./src/api/module.ts",
"./api/domain": "./src/api/domain/index.ts",
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css"
@ -70,4 +72,4 @@
"sequelize": "^6.37.8",
"zod": "^4.3.6"
}
}
}

View File

@ -2,3 +2,4 @@ export * from "./company-verifactu-profile";
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";
export * from "./recurring-proformas";

View File

@ -11,6 +11,7 @@ import {
type ProformaSeriesValidator,
type ProformaTargetInvoiceSeriesValidator,
type ProformaTaxResolver,
ProformaTaxConfigResolver,
} from "../services";
export const buildProformaCreator = (deps: {
@ -23,6 +24,10 @@ export const buildProformaCreator = (deps: {
companiesServices: ICompanyPublicServices;
customerServices: ICustomerPublicServices;
audit: IAudit;
taxConfigResolver?: ProformaTaxConfigResolver;
}): IProformaCreator => {
return new ProformaCreator(deps);
return new ProformaCreator({
...deps,
taxConfigResolver: deps.taxConfigResolver ?? new ProformaTaxConfigResolver(deps.taxResolver),
});
};

View File

@ -1,3 +1,4 @@
export * from "./build-proforma-tax-resolver";
export * from "./proforma-payment-resolver";
export * from "./proforma-tax-resolver";
export * from "./proforma-tax-config-resolver";

View File

@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Collection, Maybe, Result } from "@repo/rdx-utils";
import { ProformaTaxConfigResolver } from "./proforma-tax-config-resolver";
const required = <T>(result: { data: T }): T => result.data;
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000901"));
const atDate = required(UtcDate.createFromISO("2026-09-01"));
const buildResolver = (options?: { missing?: boolean; onLookup?: (params: unknown) => void }) =>
new ProformaTaxConfigResolver({
findActiveTaxDefinitionByCode: async (params: unknown) => {
options?.onLookup?.(params);
if (options?.missing) return Result.ok(Maybe.none());
return Result.ok(Maybe.some({ allowedSurchargeCodes: new Collection(["rec_5_2"]) }));
},
} as never);
test("resuelve defaults fiscales y conserva flags del customer", async () => {
const result = await buildResolver().resolve({
companyId, atDate, customer: { usesEquivalenceSurcharge: false, usesRetention: false },
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.defaultIvaCode.unwrap(), "iva_21");
assert.equal(result.data.defaultRecCode.isNone(), true);
assert.equal(result.data.defaultRetentionCode.isNone(), true);
});
test("resuelve recargo, retención y override con los códigos vigentes", async () => {
const result = await buildResolver().resolve({
companyId, atDate, customer: { usesEquivalenceSurcharge: true, usesRetention: true },
override: { defaultIvaCode: Maybe.some("iva_21"), defaultRecCode: Maybe.some("rec_5_2"), defaultRetentionCode: Maybe.some("retention_15") },
});
assert.equal(result.isSuccess, true);
assert.equal(result.data.defaultRecCode.unwrap(), "rec_5_2");
assert.equal(result.data.defaultRetentionCode.unwrap(), "retention_15");
});
test("propaga transaction y devuelve error cuando una definición fiscal no está disponible", async () => {
const transaction = {};
let receivedTransaction: unknown;
const missing = buildResolver({ missing: true, onLookup: (params) => { receivedTransaction = (params as { transaction?: unknown }).transaction; } });
const result = await missing.resolve({
companyId, atDate, customer: { usesEquivalenceSurcharge: true, usesRetention: false }, transaction,
});
assert.equal(result.isFailure, true);
assert.equal(receivedTransaction, transaction);
});

View File

@ -0,0 +1,101 @@
import type { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ProformaTaxConfig } from "../../../../domain";
import type { ProformaTaxConfigInputProps } from "../../models";
import type { ProformaTaxResolver } from "./proforma-tax-resolver";
export interface ProformaTaxConfigCustomerContext {
usesEquivalenceSurcharge: boolean;
usesRetention: boolean;
}
export class ProformaTaxConfigResolver {
public constructor(private readonly taxResolver: ProformaTaxResolver) {}
public async resolve(params: {
companyId: UniqueID;
atDate: UtcDate;
customer: ProformaTaxConfigCustomerContext;
override?: ProformaTaxConfigInputProps;
transaction?: unknown;
}): Promise<Result<ProformaTaxConfig, Error>> {
const taxMode = params.override?.taxMode ?? "single";
const defaultIvaCode = params.override?.defaultIvaCode ?? Maybe.some("iva_21");
const usesEquivalenceSurcharge =
params.override?.usesEquivalenceSurcharge ?? params.customer.usesEquivalenceSurcharge;
const usesRetention = params.override?.usesRetention ?? params.customer.usesRetention;
const defaultRecCode = await this.resolveDefaultRecCode({
companyId: params.companyId,
atDate: params.atDate,
defaultIvaCode,
usesEquivalenceSurcharge,
requestedCode: params.override?.defaultRecCode,
transaction: params.transaction,
});
if (defaultRecCode.isFailure) return Result.fail(defaultRecCode.error);
const defaultRetentionCode = await this.resolveRetentionCode({
companyId: params.companyId,
atDate: params.atDate,
usesRetention,
requestedCode: params.override?.defaultRetentionCode,
transaction: params.transaction,
});
if (defaultRetentionCode.isFailure) return Result.fail(defaultRetentionCode.error);
return ProformaTaxConfig.create({
taxMode,
defaultIvaCode,
usesEquivalenceSurcharge,
defaultRecCode: defaultRecCode.data,
usesRetention,
defaultRetentionCode: defaultRetentionCode.data,
});
}
private async resolveDefaultRecCode(params: {
companyId: UniqueID;
atDate: UtcDate;
defaultIvaCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
requestedCode?: Maybe<string>;
transaction?: unknown;
}): Promise<Result<Maybe<string>, Error>> {
if (!params.usesEquivalenceSurcharge || params.defaultIvaCode.isNone()) return Result.ok(Maybe.none());
const ivaCode = params.defaultIvaCode.unwrap();
const ivaDefinition = await this.taxResolver.findActiveTaxDefinitionByCode({
companyId: params.companyId, atDate: params.atDate, code: ivaCode, transaction: params.transaction,
});
if (ivaDefinition.isFailure) return Result.fail(ivaDefinition.error);
if (ivaDefinition.data.isNone()) return Result.fail(new Error(`Tax definition not found: ${ivaCode}`));
const allowed = ivaDefinition.data.unwrap().allowedSurchargeCodes.getAll();
const requested = this.maybeValue(params.requestedCode) ?? this.defaultRecForIva(ivaCode);
if (requested && allowed.includes(requested)) return Result.ok(Maybe.some(requested));
const fallback = allowed[0] ?? null;
return Result.ok(fallback ? Maybe.some(fallback) : Maybe.none());
}
private async resolveRetentionCode(params: {
companyId: UniqueID;
atDate: UtcDate;
usesRetention: boolean;
requestedCode?: Maybe<string>;
transaction?: unknown;
}): Promise<Result<Maybe<string>, Error>> {
if (!params.usesRetention) return Result.ok(Maybe.none());
const retentionCode = this.maybeValue(params.requestedCode) ?? "retention_15";
const definition = await this.taxResolver.findActiveTaxDefinitionByCode({
companyId: params.companyId, atDate: params.atDate, code: retentionCode, transaction: params.transaction,
});
if (definition.isFailure) return Result.fail(definition.error);
if (definition.data.isNone()) return Result.fail(new Error(`Tax definition not found: ${retentionCode}`));
return Result.ok(Maybe.some(retentionCode));
}
private defaultRecForIva(ivaCode: string): string | null {
return { iva_21: "rec_5_2", iva_10: "rec_1_4", iva_4: "rec_0_5", iva_0: "rec_0" }[ivaCode] ?? null;
}
private maybeValue(value: Maybe<string> | undefined): string | null {
return value?.isSome() ? value.unwrap() : null;
}
}

View File

@ -14,18 +14,21 @@ import type { ProformaItemTaxCodesInput } from "../../models";
export interface EnsureProformaTaxRegimeByCodeParams {
companyId: UniqueID;
code: Maybe<string>;
transaction?: unknown;
}
export interface EnsureProformaItemTaxesParams {
companyId: UniqueID;
atDate: UtcDate;
taxCodes: ProformaItemTaxCodesInput;
transaction?: unknown;
}
export interface FindActiveProformaTaxDefinitionByCodeParams {
companyId: UniqueID;
atDate: UtcDate;
code: string;
transaction?: unknown;
}
/**
@ -53,10 +56,9 @@ export class ProformaTaxResolver {
) {}
// Solo asegurarnos que existe un TaxRegime con ese "code"
public async ensureTaxRegimeByCode(params: {
companyId: UniqueID;
code: Maybe<string>;
}): Promise<Result<void, Error>> {
public async ensureTaxRegimeByCode(
params: EnsureProformaTaxRegimeByCodeParams
): Promise<Result<void, Error>> {
if (params.code.isNone()) {
return Result.ok(undefined);
}
@ -66,6 +68,7 @@ export class ProformaTaxResolver {
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
companyId: params.companyId,
code,
transaction: params.transaction,
});
if (result.isFailure) {
@ -92,6 +95,7 @@ export class ProformaTaxResolver {
companyId: params.companyId,
codes,
atDate: params.atDate,
transaction: params.transaction,
});
if (definitions.isFailure) {
@ -128,6 +132,7 @@ export class ProformaTaxResolver {
companyId: params.companyId,
code: params.code,
atDate: params.atDate,
transaction: params.transaction,
});
}

View File

@ -10,18 +10,18 @@ import {
InvoiceNumber,
InvoiceSerie,
Proforma,
ProformaTaxConfig,
} from "../../../domain";
import {
PROFORMA_AUDIT_ACTIONS,
toProformaAuditState,
toProformaAuditTarget,
} from "../audit/proforma-audit";
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
import type { ProformaCreateInputProps } from "../models";
import type { IProformaRepository } from "../repositories";
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
import { ProformaTaxConfigResolver } from "./catalog-resolver/proforma-tax-config-resolver";
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
@ -43,6 +43,7 @@ export class ProformaCreator implements IProformaCreator {
repository: IProformaRepository;
numberService: IProformaNumberGenerator;
taxResolver: ProformaTaxResolver;
taxConfigResolver?: ProformaTaxConfigResolver;
paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
@ -213,6 +214,7 @@ export class ProformaCreator implements IProformaCreator {
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
companyId: _newProps.companyId,
code: _newProps.taxRegimeCode,
transaction,
});
if (taxRegimeResult.isFailure) {
@ -230,11 +232,13 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(paymentMethodResult.error);
}
const taxConfigResult = await this.resolveTaxConfig({
const taxConfigResolver = this.deps.taxConfigResolver ?? new ProformaTaxConfigResolver(this.deps.taxResolver);
const taxConfigResult = await taxConfigResolver.resolve({
companyId: _newProps.companyId,
atDate: _newProps.proformaDate,
customer,
override: props.taxConfig,
transaction,
});
if (taxConfigResult.isFailure) {
@ -251,6 +255,7 @@ export class ProformaCreator implements IProformaCreator {
companyId: _newProps.companyId,
atDate: _newProps.proformaDate,
taxCodes: item.taxes,
transaction,
});
if (taxes.isFailure) {
@ -354,172 +359,4 @@ export class ProformaCreator implements IProformaCreator {
return Result.ok(resolvedDefaultPaymentTermId);
}
private async resolveTaxConfig(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];
customer: Awaited<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
infer T,
Error
>
? T
: never;
override?: ProformaTaxConfigInputProps;
}): Promise<Result<ProformaTaxConfig, Error>> {
const taxMode = params.override?.taxMode ?? "single";
const defaultIvaCode =
params.override?.defaultIvaCode ??
Maybe.some(this.resolveDefaultIvaCodeFromCustomer(params.customer) ?? "iva_21");
const usesEquivalenceSurcharge =
params.override?.usesEquivalenceSurcharge ?? params.customer.usesEquivalenceSurcharge;
const usesRetention = params.override?.usesRetention ?? params.customer.usesRetention;
const defaultRecCode = await this.resolveDefaultRecCode({
companyId: params.companyId,
atDate: params.atDate,
defaultIvaCode,
usesEquivalenceSurcharge,
requestedCode: params.override?.defaultRecCode,
});
if (defaultRecCode.isFailure) {
return Result.fail(defaultRecCode.error);
}
const defaultRetentionCode = await this.resolveRetentionCode({
companyId: params.companyId,
atDate: params.atDate,
usesRetention,
requestedCode: params.override?.defaultRetentionCode,
});
if (defaultRetentionCode.isFailure) {
return Result.fail(defaultRetentionCode.error);
}
const createdTaxConfig = ProformaTaxConfig.create({
taxMode,
defaultIvaCode,
usesEquivalenceSurcharge,
defaultRecCode: defaultRecCode.data,
usesRetention,
defaultRetentionCode: defaultRetentionCode.data,
});
if (createdTaxConfig.isFailure) {
return Result.fail(createdTaxConfig.error);
}
return Result.ok(createdTaxConfig.data);
}
private resolveDefaultIvaCodeFromCustomer(
_customer: Awaited<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
infer T,
Error
>
? T
: never
): string | null {
return null;
}
private async resolveDefaultRecCode(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];
defaultIvaCode: Maybe<string>;
usesEquivalenceSurcharge: boolean;
requestedCode?: Maybe<string>;
}): Promise<Result<Maybe<string>, Error>> {
if (!params.usesEquivalenceSurcharge) {
return Result.ok(Maybe.none());
}
const ivaCode = this.maybeValue(params.defaultIvaCode);
if (!ivaCode) {
return Result.ok(Maybe.none());
}
const ivaDefinition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({
companyId: params.companyId,
atDate: params.atDate,
code: ivaCode,
});
if (ivaDefinition.isFailure) {
return Result.fail(ivaDefinition.error);
}
if (ivaDefinition.data.isNone()) {
return Result.fail(new Error(`Tax definition not found: ${ivaCode}`));
}
const allowedSurchargeCodes = ivaDefinition.data.unwrap().allowedSurchargeCodes.getAll();
const requestedCode = this.maybeValue(params.requestedCode) ?? this.defaultRecForIva(ivaCode);
if (requestedCode && allowedSurchargeCodes.includes(requestedCode)) {
return Result.ok(Maybe.some(requestedCode));
}
const fallbackCode = allowedSurchargeCodes[0] ?? null;
return Result.ok(fallbackCode ? Maybe.some(fallbackCode) : Maybe.none());
}
private async resolveRetentionCode(params: {
companyId: UniqueID;
atDate: IProformaCreateProps["proformaDate"];
usesRetention: boolean;
requestedCode?: Maybe<string>;
}): Promise<Result<Maybe<string>, Error>> {
if (!params.usesRetention) {
return Result.ok(Maybe.none());
}
const retentionCode = this.maybeValue(params.requestedCode) ?? "retention_15";
const definition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({
companyId: params.companyId,
atDate: params.atDate,
code: retentionCode,
});
if (definition.isFailure) {
return Result.fail(definition.error);
}
if (definition.data.isNone()) {
return Result.fail(new Error(`Tax definition not found: ${retentionCode}`));
}
return Result.ok(Maybe.some(retentionCode));
}
private defaultRecForIva(ivaCode: string): string | null {
const defaults: Record<string, string> = {
iva_21: "rec_5_2",
iva_10: "rec_1_4",
iva_4: "rec_0_5",
iva_0: "rec_0",
};
return defaults[ivaCode] ?? null;
}
private maybeValue(value: Maybe<string> | string | null | undefined): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "object" && "isSome" in value && typeof value.isSome === "function") {
return value.isSome() ? value.unwrap() : null;
}
return null;
}
}

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-audit";

View File

@ -0,0 +1,37 @@
import type { RecurringProforma } from "../../../domain";
const nullable = <T, U>(value: { isSome(): boolean; unwrap(): T }, map: (item: T) => U): U | null =>
value.isSome() ? map(value.unwrap()) : null;
export const RECURRING_PROFORMA_AUDIT_ACTIONS = {
CREATED: "recurring_proforma.created",
UPDATED: "recurring_proforma.updated",
ACTIVATED: "recurring_proforma.activated",
PAUSED: "recurring_proforma.paused",
RESUMED: "recurring_proforma.resumed",
COMPLETED: "recurring_proforma.completed",
ARCHIVED: "recurring_proforma.archived",
DELETED: "recurring_proforma.deleted",
DUPLICATED: "recurring_proforma.duplicated",
CREATED_FROM_PROFORMA: "recurring_proforma.created_from_proforma",
} as const;
export const toRecurringProformaAuditState = (source: RecurringProforma) => ({
id: source.id.toPrimitive(),
companyId: source.companyId.toPrimitive(),
name: source.name,
description: nullable(source.description, (value) => value),
customerId: nullable(source.customerId, (value) => value.toPrimitive()),
proformaSeries: nullable(source.proformaSeries, (value) => value.toPrimitive()),
issuedInvoiceSeries: nullable(source.issuedInvoiceSeries, (value) => value.toPrimitive()),
status: source.status,
nextOccurrenceDate: nullable(source.nextOccurrenceDate, (value) => value.toISOString()),
itemCount: source.items.size(),
});
export const toRecurringProformaAuditTarget = (source: RecurringProforma) => ({
type: "recurring_proforma",
id: source.id.toPrimitive(),
companyId: source.companyId.toPrimitive(),
label: source.name,
});

View File

@ -0,0 +1,4 @@
export * from "./repositories";
export * from "./audit";
export * from "./models";
export * from "./services";

View File

@ -0,0 +1 @@
export * from "./recurring-proforma-read.models";

View File

@ -0,0 +1,100 @@
import type { DiscountPercentage } from "@erp/core/api";
import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import type {
InvoiceSerie,
ItemDescription,
MonthlyRule,
RecurrenceUnit,
RecurringProformaOccurrenceStatus,
RecurringProformaStatus,
} from "../../../domain";
export type RecurringProformaScheduleReadModel = {
startDate: Date;
endDate: Date | null;
unit: RecurrenceUnit;
interval: number;
monthlyRule: MonthlyRule | null;
};
export type RecurringProformaListItemReadModel = {
id: UniqueID;
name: string;
customerId: UniqueID | null;
status: RecurringProformaStatus;
schedule: RecurringProformaScheduleReadModel | null;
nextOccurrenceDate: Date | null;
updatedAt: Date;
};
export type RecurringProformaItemReadModel = {
id: UniqueID;
position: number;
description: ItemDescription | null;
quantity: unknown | null;
unitAmount: unknown | null;
itemDiscountPercentage: DiscountPercentage | null;
ivaCode: string | null;
recCode: string | null;
retentionCode: string | null;
};
export type RecurringProformaDetailReadModel = {
id: UniqueID;
companyId: UniqueID;
name: string;
status: RecurringProformaStatus;
customerId: UniqueID | null;
description: string | null;
paymentTermId: UniqueID | null;
paymentMethodId: UniqueID | null;
language: LanguageCode | null;
currency: CurrencyCode | null;
proformaSeries: InvoiceSerie | null;
issuedInvoiceSeries: InvoiceSerie | null;
globalDiscountPercentage: DiscountPercentage | null;
schedule: RecurringProformaScheduleReadModel | null;
nextOccurrenceDate: Date | null;
items: readonly RecurringProformaItemReadModel[];
createdAt: Date;
updatedAt: Date;
};
export type RecurringProformaOccurrenceReadModel = {
id: UniqueID;
scheduledDate: Date;
status: RecurringProformaOccurrenceStatus;
attemptCount: number;
lastAttemptAt: Date | null;
processingStartedAt: Date | null;
lastErrorCode: string | null;
lastErrorMessage: string | null;
proformaId: UniqueID | null;
generatedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
export type RecurringProformaPage<T> = {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: readonly T[];
};
export type ListRecurringProformasQuery = {
companyId: UniqueID;
page: number;
limit: number;
status?: RecurringProformaStatus;
search?: string;
};
export type ListRecurringProformaOccurrencesQuery = {
companyId: UniqueID;
recurringProformaId: UniqueID;
page: number;
limit: number;
};

View File

@ -0,0 +1,2 @@
export * from "./recurring-proforma-repository.interface";
export * from "./recurring-proforma-occurrence-repository.interface";

View File

@ -0,0 +1,41 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { RecurringProformaOccurrence } from "../../../domain";
import type {
ListRecurringProformaOccurrencesQuery,
RecurringProformaOccurrenceReadModel,
RecurringProformaPage,
} from "../models";
export interface IRecurringProformaOccurrenceRepository {
save(
occurrence: RecurringProformaOccurrence,
transaction?: unknown
): Promise<Result<void, Error>>;
findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<RecurringProformaOccurrence, Error>>;
findByRecurringProformaAndScheduledDate(
recurringProformaId: UniqueID,
scheduledDate: Date,
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<RecurringProformaOccurrence>, Error>>;
latestScheduledDate(
recurringProformaId: UniqueID,
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<Date>, Error>>;
hasFailedOccurrences(
recurringProformaId: UniqueID,
companyId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>>;
listByRecurringProformaInCompany(
query: ListRecurringProformaOccurrencesQuery,
transaction?: unknown
): Promise<Result<RecurringProformaPage<RecurringProformaOccurrenceReadModel>, Error>>;
}

View File

@ -0,0 +1,47 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { RecurringProforma } from "../../../domain";
import type {
ListRecurringProformasQuery,
RecurringProformaListItemReadModel,
RecurringProformaPage,
} from "../models";
export type DueRecurringProforma = {
recurringProformaId: UniqueID;
companyId: UniqueID;
nextOccurrenceDate: Date;
};
export interface IRecurringProformaRepository {
save(recurringProforma: RecurringProforma, transaction?: unknown): Promise<Result<void, Error>>;
findByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<RecurringProforma, Error>>;
findByIdInCompanyForUpdate(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<RecurringProforma, Error>>;
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>>;
hasOccurrences(
recurringProformaId: UniqueID,
companyId: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>>;
findDue(
dueThrough: Date,
limit: number
): Promise<Result<readonly DueRecurringProforma[], Error>>;
listInCompany(
query: ListRecurringProformasQuery,
transaction?: unknown
): Promise<Result<RecurringProformaPage<RecurringProformaListItemReadModel>, Error>>;
}

View File

@ -0,0 +1,20 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import type {
IRecurringProformaOccurrenceRepository,
IRecurringProformaRepository,
} from "../repositories";
import type { RecurringProformaMaterializer } from "./recurring-proforma-materializer";
import { RecurringProformaOccurrenceProcessor } from "./recurring-proforma-occurrence-processor";
/** Construye el processor recurrente desde infraestructura neutral al transporte. */
export function createRecurringProformaOccurrenceProcessor(deps: {
transactionManager: ITransactionManager;
recurringProformaRepository: IRecurringProformaRepository;
occurrenceRepository: IRecurringProformaOccurrenceRepository;
materializer: RecurringProformaMaterializer;
audit: IAudit;
}): RecurringProformaOccurrenceProcessor {
return new RecurringProformaOccurrenceProcessor(deps);
}

View File

@ -0,0 +1,8 @@
export * from "./recurring-proforma-services";
export * from "./recurring-proforma-updater";
export * from "./recurring-proforma-activation-readiness-checker";
export * from "./recurring-proforma-from-proforma-creator";
export * from "./recurring-proforma-materializer";
export * from "./recurring-proforma-occurrence-processor";
export * from "./create-recurring-proforma-occurrence-processor";
export * from "./recurring-proforma-capabilities";

View File

@ -0,0 +1,87 @@
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { RecurringProforma } from "../../../domain";
import type { ProformaPaymentResolver } from "../../proformas/services/catalog-resolver/proforma-payment-resolver";
import type { ProformaSeriesValidator } from "../../proformas/services/proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "../../proformas/services/proforma-target-invoice-series-validator.interface";
export type RecurringProformaActivationBlockerCode =
| "missing_customer"
| "customer_unavailable"
| "missing_items"
| "missing_schedule"
| "invalid_payment_term"
| "invalid_payment_method"
| "invalid_proforma_series"
| "invalid_issued_invoice_series";
export interface RecurringProformaActivationReadiness {
canActivate: boolean;
blockers: ReadonlyArray<{ code: RecurringProformaActivationBlockerCode }>;
}
export class RecurringProformaActivationReadinessChecker {
public constructor(
private readonly deps: {
customerServices: ICustomerPublicServices;
paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
}
) {}
public async check(params: {
recurringProforma: RecurringProforma;
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<RecurringProformaActivationReadiness, Error>> {
const blockers: Array<{ code: RecurringProformaActivationBlockerCode }> = [];
const recurringProforma = params.recurringProforma;
if (recurringProforma.customerId.isNone()) {
blockers.push({ code: "missing_customer" });
} else {
const customer = await this.deps.customerServices.getCustomerById(
recurringProforma.customerId.unwrap(),
{ companyId: params.companyId, transaction: params.transaction }
);
if (customer.isFailure) blockers.push({ code: "customer_unavailable" });
}
if (recurringProforma.items.size() === 0) blockers.push({ code: "missing_items" });
if (recurringProforma.schedule.isNone()) blockers.push({ code: "missing_schedule" });
const paymentTerm = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: recurringProforma.paymentTermId,
transaction: params.transaction,
});
if (paymentTerm.isFailure && recurringProforma.paymentTermId.isSome()) {
blockers.push({ code: "invalid_payment_term" });
}
const paymentMethod = await this.deps.paymentResolver.ensurePaymentMethodById({
companyId: params.companyId,
id: recurringProforma.paymentMethodId,
transaction: params.transaction,
});
if (paymentMethod.isFailure && recurringProforma.paymentMethodId.isSome()) {
blockers.push({ code: "invalid_payment_method" });
}
if (recurringProforma.proformaSeries.isSome()) {
const proformaSeries = await this.deps.proformaSeriesValidator.ensureValidProformaSeries({
companyId: params.companyId,
proformaSeriesCode: recurringProforma.proformaSeries,
transaction: params.transaction,
});
if (proformaSeries.isFailure) blockers.push({ code: "invalid_proforma_series" });
}
if (recurringProforma.issuedInvoiceSeries.isSome()) {
const issuedInvoiceSeries = await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
companyId: params.companyId,
targetInvoiceSeriesCode: recurringProforma.issuedInvoiceSeries,
transaction: params.transaction,
});
if (issuedInvoiceSeries.isFailure) blockers.push({ code: "invalid_issued_invoice_series" });
}
return Result.ok({ canActivate: blockers.length === 0, blockers });
}
}

View File

@ -0,0 +1,202 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
ItemAmount,
ItemDescription,
ItemQuantity,
RecurrenceSchedule,
RecurringProforma,
RecurringProformaOccurrence,
} from "../../../domain";
import { toRecurringProformaAuditTarget } from "../audit";
import { RecurringProformaCommands, RecurringProformaReader } from "./recurring-proforma-capabilities";
const required = <T>(result: { data: T }): T => result.data;
const companyA = required(UniqueID.create("01900000-0000-7000-8000-000000000701"));
const companyB = required(UniqueID.create("01900000-0000-7000-8000-000000000702"));
const recurringId = required(UniqueID.create("01900000-0000-7000-8000-000000000703"));
const transaction = { id: "transaction" };
const transactionManager = (): ITransactionManager => ({
start: async () => undefined,
getTransaction: () => transaction,
complete: async <T>(work: (currentTransaction: unknown) => Promise<T>) => work(transaction),
commit: async () => undefined,
rollback: async () => undefined,
});
const buildRecurring = () => {
const currency = required(CurrencyCode.create("EUR"));
const language = required(LanguageCode.create("es"));
const discount = required(DiscountPercentage.create({ value: 500 }));
const schedule = required(RecurrenceSchedule.create({ startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month", interval: 1, monthlyRule: "same_day" }));
return required(RecurringProforma.create({
companyId: companyA,
name: "Cuota mensual",
customerId: Maybe.none(),
description: Maybe.none(),
paymentTermId: Maybe.none(),
paymentMethodId: Maybe.none(),
language: Maybe.none(),
currency: Maybe.none(),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
globalDiscountPercentage: Maybe.some(discount),
schedule: Maybe.some(schedule),
items: [{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.some(discount),
ivaCode: Maybe.some("general"), recCode: Maybe.some("rec"), retentionCode: Maybe.some("ret"),
}],
createdAt: new Date("2027-01-01T00:00:00.000Z"),
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
}, recurringId));
};
test("toRecurringProformaAuditTarget incluye el nombre como label", () => {
const recurring = buildRecurring();
const target = toRecurringProformaAuditTarget(recurring);
assert.equal(target.label, "Cuota mensual");
});
test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => {
let receivedQuery: unknown;
let receivedTransaction: unknown;
const item = { id: recurringId, name: "Cuota mensual", customerId: null, status: "draft" as const, schedule: null, nextOccurrenceDate: null, updatedAt: new Date("2027-01-02T00:00:00.000Z") };
const reader = new RecurringProformaReader({
listInCompany: async (query, currentTransaction) => {
receivedQuery = query; receivedTransaction = currentTransaction;
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
},
} as never, {} as never, transactionManager());
const result = await reader.list({ companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" });
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" });
assert.equal(receivedTransaction, transaction);
assert.deepEqual(result.data, { page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false);
});
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
let receivedCompany: UniqueID | undefined;
const recurring = buildRecurring();
const reader = new RecurringProformaReader({
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
receivedCompany = companyId;
assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction);
return Result.ok(recurring);
},
} as never, {} as never, transactionManager());
const result = await reader.detail({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(receivedCompany?.equals(companyA), true);
assert.equal(result.data.customerId, null);
assert.equal(result.data.paymentTermId, null);
assert.equal(result.data.language, null);
assert.equal(result.data.schedule?.unit, "month");
assert.equal(result.data.globalDiscountPercentage?.value, 500);
assert.equal(result.data.items[0]?.description, "Servicio");
assert.equal(result.data.items[0]?.ivaCode, "general");
assert.equal(result.data.items[0]?.recCode, "rec");
assert.equal(result.data.items[0]?.retentionCode, "ret");
});
test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => {
let receivedQuery: unknown;
const proformaId = UniqueID.generateNewID();
const generated = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-02-15T00:00:00.000Z"), status: "generated", attemptCount: 1, proformaId: Maybe.some(proformaId), generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")), createdAt: new Date(), updatedAt: new Date() }));
const failed = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-01-15T00:00:00.000Z"), status: "failed", attemptCount: 2, lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")), lastErrorCode: Maybe.some("failed"), lastErrorMessage: Maybe.some("Error"), createdAt: new Date(), updatedAt: new Date() }));
const toRead = (source: typeof generated) => ({ id: source.id, scheduledDate: source.scheduledDate, status: source.status, attemptCount: source.attemptCount, lastAttemptAt: source.lastAttemptAt.match((value) => value, () => null), processingStartedAt: null, lastErrorCode: source.lastErrorCode.match((value) => value, () => null), lastErrorMessage: source.lastErrorMessage.match((value) => value, () => null), proformaId: source.proformaId.match((value) => value, () => null), generatedAt: source.generatedAt.match((value) => value, () => null), createdAt: source.createdAt, updatedAt: source.updatedAt });
const reader = new RecurringProformaReader({} as never, {
listByRecurringProformaInCompany: async (query) => { receivedQuery = query; return Result.ok({ page: 1, perPage: 10, totalItems: 2, totalPages: 1, items: [toRead(generated), toRead(failed)] }); },
} as never, transactionManager());
const result = await reader.occurrences({ companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 });
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 });
assert.equal(result.data.items[0]?.proformaId?.equals(proformaId), true);
assert.equal(result.data.items[1]?.lastErrorCode, "failed");
assert.equal(result.data.items[1]?.attemptCount, 2);
});
test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => {
const calls: Array<{ action: string; transaction: unknown }> = [];
const commands = new RecurringProformaCommands({
statusChanger: { change: async (params: { action: string; transaction: unknown; companyId: UniqueID; id: UniqueID }) => {
assert.equal(params.companyId.equals(companyA), true); assert.equal(params.id.equals(recurringId), true);
calls.push({ action: params.action, transaction: params.transaction }); return Result.ok(buildRecurring());
} },
creator: {}, updater: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {},
transactionManager: transactionManager(),
} as never);
for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) {
const result = await commands[action]({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
}
assert.deepEqual(calls.map((call) => call.action), ["activate", "pause", "resume", "complete", "archive"]);
assert.equal(calls.every((call) => call.transaction === transaction), true);
});
test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => {
let received: { name: string; schedule: unknown; transaction: unknown } | undefined;
const schedule = { startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month" as const, interval: 1, monthlyRule: "same_day" as const };
const commands = new RecurringProformaCommands({
fromProformaCreator: { create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { received = params; return Result.ok(buildRecurring()); } },
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
} as never);
const result = await commands.fromProforma({ companyId: companyA, proformaId: recurringId, name: "Desde proforma", schedule });
assert.equal(result.isSuccess, true);
assert.equal(received?.name, "Desde proforma");
assert.deepEqual(received?.schedule, schedule);
assert.equal(received?.transaction, transaction);
});
test("RecurringProformaCommands propaga create, update, duplicate y delete sin normalizar el patch", async () => {
const calls: Record<string, unknown> = {};
const patch = { paymentTermId: null };
const commands = new RecurringProformaCommands({
creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown }) => { calls.create = params; return Result.ok(buildRecurring()); } },
updater: { update: async (params: { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }) => { calls.update = params; return Result.ok(buildRecurring()); } },
duplicator: { duplicate: async (params: { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }) => { calls.duplicate = params; return Result.ok(buildRecurring()); } },
deleter: { delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { calls.delete = params; return Result.ok(true); } },
statusChanger: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
} as never);
assert.equal((await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })).isSuccess, true);
assert.equal((await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, true);
assert.equal((await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, true);
assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true);
const create = calls.create as { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown };
const update = calls.update as { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown };
const duplicate = calls.duplicate as { companyId: UniqueID; sourceId: UniqueID; transaction: unknown };
const deleted = calls.delete as { companyId: UniqueID; id: UniqueID; transaction: unknown };
assert.equal(create.companyId.equals(companyA), true); assert.equal(create.name, "Nueva"); assert.equal(create.description?.unwrap(), "Descripción"); assert.equal(create.transaction, transaction);
assert.equal(update.companyId.equals(companyA), true); assert.equal(update.id.equals(recurringId), true); assert.equal(update.patch.paymentTermId, null); assert.equal(update.transaction, transaction);
assert.equal(duplicate.companyId.equals(companyA), true); assert.equal(duplicate.sourceId.equals(recurringId), true); assert.equal(duplicate.transaction, transaction);
assert.equal(deleted.companyId.equals(companyA), true); assert.equal(deleted.id.equals(recurringId), true); assert.equal(deleted.transaction, transaction);
});
test("RecurringProformaCommands propaga readiness con blockers sin interpretarlos", async () => {
let repositoryCalls = 0;
let checkerCalls = 0;
const readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] };
const recurring = buildRecurring();
const commands = new RecurringProformaCommands({
repository: { findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { repositoryCalls += 1; assert.equal(companyId.equals(companyA), true); assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); return Result.ok(recurring); } },
readinessChecker: { check: async (params: { companyId: UniqueID; recurringProforma: RecurringProforma; transaction: unknown }) => { checkerCalls += 1; assert.equal(params.companyId.equals(companyA), true); assert.equal(params.recurringProforma, recurring); assert.equal(params.transaction, transaction); return Result.ok(readiness); } },
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, transactionManager: transactionManager(),
} as never);
const result = await commands.activationReadiness({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(result.data, readiness);
assert.equal(repositoryCalls, 1);
assert.equal(checkerCalls, 1);
});

View File

@ -0,0 +1,258 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { RecurrenceScheduleProps, RecurringProforma } from "../../../domain";
import type {
ListRecurringProformaOccurrencesQuery,
ListRecurringProformasQuery,
RecurringProformaDetailReadModel,
RecurringProformaOccurrenceReadModel,
RecurringProformaPage,
RecurringProformaScheduleReadModel,
} from "../models";
import type {
IRecurringProformaOccurrenceRepository,
IRecurringProformaRepository,
} from "../repositories";
import type { RecurringProformaActivationReadinessChecker } from "./recurring-proforma-activation-readiness-checker";
import type { RecurringProformaFromProformaCreator } from "./recurring-proforma-from-proforma-creator";
import type {
RecurringProformaCreator,
RecurringProformaDeleter,
RecurringProformaDuplicator,
RecurringProformaStatusChanger,
} from "./recurring-proforma-services";
import type {
RecurringProformaPatchProps,
RecurringProformaUpdater,
} from "./recurring-proforma-updater";
/** Lecturas ligeras y completas que consumen los adapters sin conocer persistencia. */
export class RecurringProformaReader {
public constructor(
private readonly repository: IRecurringProformaRepository,
private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository,
private readonly transactionManager: ITransactionManager
) {}
public list(query: ListRecurringProformasQuery) {
return this.transactionManager.complete((transaction) =>
this.repository.listInCompany(query, transaction)
);
}
public detail(params: { companyId: UniqueID; id: UniqueID }) {
return this.transactionManager.complete(async (transaction) => {
const found = await this.repository.findByIdInCompany(
params.companyId,
params.id,
transaction
);
return found.isFailure ? Result.fail(found.error) : Result.ok(this.toDetail(found.data));
});
}
public occurrences(
query: ListRecurringProformaOccurrencesQuery
): Promise<Result<RecurringProformaPage<RecurringProformaOccurrenceReadModel>, Error>> {
return this.transactionManager.complete((transaction) =>
this.occurrenceRepository.listByRecurringProformaInCompany(query, transaction)
);
}
private toDetail(source: RecurringProforma): RecurringProformaDetailReadModel {
const schedule = source.schedule.map<RecurringProformaScheduleReadModel>((value) => ({
startDate: value.startDate,
endDate: value.endDate,
unit: value.unit,
interval: value.interval,
monthlyRule: value.monthlyRule,
}));
return {
id: source.id,
companyId: source.companyId,
name: source.name,
status: source.status,
customerId: source.customerId.match(
(value) => value,
() => null
),
description: source.description.match(
(value) => value,
() => null
),
paymentTermId: source.paymentTermId.match(
(value) => value,
() => null
),
paymentMethodId: source.paymentMethodId.match(
(value) => value,
() => null
),
language: source.language.match(
(value) => value,
() => null
),
currency: source.currency.match(
(value) => value,
() => null
),
proformaSeries: source.proformaSeries.match(
(value) => value,
() => null
),
issuedInvoiceSeries: source.issuedInvoiceSeries.match(
(value) => value,
() => null
),
globalDiscountPercentage: source.globalDiscountPercentage.match(
(value) => value,
() => null
),
schedule: schedule.match(
(value) => value,
() => null
),
nextOccurrenceDate: source.nextOccurrenceDate.match(
(value) => value,
() => null
),
items: source.items.getAll().map((item, index) => ({
id: item.id,
position: index + 1,
description: item.description.match(
(value) => value,
() => null
),
quantity: item.quantity.match(
(value) => value,
() => null
),
unitAmount: item.unitAmount.match(
(value) => value,
() => null
),
itemDiscountPercentage: item.itemDiscountPercentage.match(
(value) => value,
() => null
),
ivaCode: item.ivaCode.match(
(value) => value,
() => null
),
recCode: item.recCode.match(
(value) => value,
() => null
),
retentionCode: item.retentionCode.match(
(value) => value,
() => null
),
})),
createdAt: source.createdAt,
updatedAt: source.updatedAt,
};
}
}
/** Fachada neutral que concentra el ownership transaccional de los comandos HTTP futuros. */
export class RecurringProformaCommands {
public constructor(
private readonly deps: {
creator: RecurringProformaCreator;
updater: RecurringProformaUpdater;
statusChanger: RecurringProformaStatusChanger;
deleter: RecurringProformaDeleter;
duplicator: RecurringProformaDuplicator;
fromProformaCreator: RecurringProformaFromProformaCreator;
readinessChecker: RecurringProformaActivationReadinessChecker;
repository: IRecurringProformaRepository;
transactionManager: ITransactionManager;
}
) {}
public create(params: { companyId: UniqueID; name: string; description?: string }) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.creator.create({
companyId: params.companyId,
name: params.name,
description: params.description === undefined ? undefined : Maybe.some(params.description),
id: UniqueID.generateNewID(),
transaction,
})
);
}
public update(params: { companyId: UniqueID; id: UniqueID; patch: RecurringProformaPatchProps }) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.updater.update({ ...params, transaction })
);
}
public changeStatus(params: {
companyId: UniqueID;
id: UniqueID;
action: "activate" | "pause" | "resume" | "complete" | "archive";
}) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.statusChanger.change({ ...params, transaction })
);
}
public activate(params: { companyId: UniqueID; id: UniqueID }) {
return this.changeStatus({ ...params, action: "activate" });
}
public pause(params: { companyId: UniqueID; id: UniqueID }) {
return this.changeStatus({ ...params, action: "pause" });
}
public resume(params: { companyId: UniqueID; id: UniqueID }) {
return this.changeStatus({ ...params, action: "resume" });
}
public complete(params: { companyId: UniqueID; id: UniqueID }) {
return this.changeStatus({ ...params, action: "complete" });
}
public archive(params: { companyId: UniqueID; id: UniqueID }) {
return this.changeStatus({ ...params, action: "archive" });
}
public delete(params: { companyId: UniqueID; id: UniqueID }) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.deleter.delete({ ...params, transaction })
);
}
public duplicate(params: { companyId: UniqueID; sourceId: UniqueID }) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.duplicator.duplicate({ ...params, id: UniqueID.generateNewID(), transaction })
);
}
public fromProforma(params: {
companyId: UniqueID;
proformaId: UniqueID;
name: string;
schedule: Omit<RecurrenceScheduleProps, "endDate"> & { endDate?: Date | null };
}) {
return this.deps.transactionManager.complete((transaction) =>
this.deps.fromProformaCreator.create({ ...params, id: UniqueID.generateNewID(), transaction })
);
}
public activationReadiness(params: { companyId: UniqueID; id: UniqueID }) {
return this.deps.transactionManager.complete(async (transaction) => {
const recurringProforma = await this.deps.repository.findByIdInCompany(
params.companyId,
params.id,
transaction
);
if (recurringProforma.isFailure) return Result.fail(recurringProforma.error);
return this.deps.readinessChecker.check({
companyId: params.companyId,
recurringProforma: recurringProforma.data,
transaction,
});
});
}
}

View File

@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
ItemAmount,
ItemDescription,
ItemQuantity,
RecurringProforma,
type RecurringProformaItemCreateProps,
} from "../../../domain";
import { RecurringProformaFromProformaCreator } from "./recurring-proforma-from-proforma-creator";
const required = <T>(result: { data: T }): T => result.data;
const buildRecurringItem = (overrides?: Partial<RecurringProformaItemCreateProps>): RecurringProformaItemCreateProps => {
const currency = required(CurrencyCode.create("EUR"));
return {
description: Maybe.some(required(ItemDescription.create("Servicio mensual"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
unitAmount: Maybe.some(
required(ItemAmount.create({ value: 1250, currency_code: currency.code }))
),
itemDiscountPercentage: Maybe.none(),
ivaCode: Maybe.some("general"),
recCode: Maybe.none(),
retentionCode: Maybe.none(),
...overrides,
};
};
const buildSourceProforma = () => {
const companyId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const paymentTermId = UniqueID.generateNewID();
const paymentMethodId = UniqueID.generateNewID();
const globalDiscountPercentage = required(DiscountPercentage.create({ value: 500 }));
const language = required(LanguageCode.create("es"));
const currency = required(CurrencyCode.create("EUR"));
const source = {
id: UniqueID.generateNewID(),
customerId,
description: Maybe.some("Plantilla comercial"),
globalDiscountPercentage,
paymentTermId: Maybe.some(paymentTermId),
paymentMethodId: Maybe.some(paymentMethodId),
languageCode: language.code,
currencyCode: currency.code,
proformaSeriesCode: Maybe.some("PF"),
targetInvoiceSeriesCode: Maybe.some("FV"),
items: {
getAll: () => [
{
description: buildRecurringItem().description,
quantity: buildRecurringItem().quantity,
unitAmount: buildRecurringItem().unitAmount,
itemDiscountPercentage: buildRecurringItem().itemDiscountPercentage,
ivaCode: () => buildRecurringItem().ivaCode,
recCode: () => buildRecurringItem().recCode,
retentionCode: () => buildRecurringItem().retentionCode,
},
],
},
};
return { companyId, source };
};
test("RecurringProformaFromProformaCreator copia la plantilla comercial y acepta schedule solicitado en draft", async () => {
const { companyId, source } = buildSourceProforma();
const saved: RecurringProforma[] = [];
const creator = new RecurringProformaFromProformaCreator({
proformaRepository: {
getByIdInCompany: async () => Result.ok(source),
} as never,
recurringProformaRepository: {
save: async (recurring) => {
saved.push(recurring);
return Result.ok(undefined);
},
} as never,
audit: {
recordMutation: async () => Result.ok(undefined),
} as never,
});
const created = await creator.create({
companyId,
proformaId: source.id,
id: UniqueID.generateNewID(),
name: "Cuota mantenimiento",
schedule: {
startDate: new Date("2026-08-31T00:00:00.000Z"),
endDate: null,
unit: "month",
interval: 1,
monthlyRule: "same_day",
},
transaction: {},
});
assert.equal(created.isSuccess, true);
assert.equal(saved.length, 1);
const recurring = created.data;
assert.equal(recurring.name, "Cuota mantenimiento");
assert.equal(recurring.status, "draft");
assert.equal(recurring.nextOccurrenceDate.isNone(), true);
assert.equal(recurring.schedule.isSome(), true);
assert.equal(recurring.schedule.unwrap().startDate.toISOString(), "2026-08-31T00:00:00.000Z");
assert.equal(recurring.schedule.unwrap().unit, "month");
assert.equal(recurring.schedule.unwrap().monthlyRule, "same_day");
assert.equal(recurring.customerId.unwrap().equals(source.customerId), true);
assert.equal(recurring.description.unwrap(), "Plantilla comercial");
assert.equal(recurring.items.getAll().length, 1);
assert.equal(recurring.items.getAll()[0]?.description.unwrap(), "Servicio mensual");
assert.equal(recurring.items.getAll()[0]?.ivaCode.unwrap(), "general");
});
test("RecurringProformaFromProformaCreator falla si el schedule solicitado es inválido", async () => {
const { companyId, source } = buildSourceProforma();
const creator = new RecurringProformaFromProformaCreator({
proformaRepository: {
getByIdInCompany: async () => Result.ok(source),
} as never,
recurringProformaRepository: {
save: async () => Result.ok(undefined),
} as never,
audit: {
recordMutation: async () => Result.ok(undefined),
} as never,
});
const created = await creator.create({
companyId,
proformaId: source.id,
id: UniqueID.generateNewID(),
name: "Inválida",
schedule: {
startDate: new Date("2026-08-31T00:00:00.000Z"),
endDate: null,
unit: "year",
interval: 1,
monthlyRule: "same_day",
},
transaction: {},
});
assert.equal(created.isFailure, true);
});

View File

@ -0,0 +1,79 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
RecurrenceSchedule,
type RecurrenceScheduleProps,
RecurringProforma,
type RecurringProformaItemCreateProps,
} from "../../../domain";
import type { IProformaRepository } from "../../proformas/repositories";
import {
RECURRING_PROFORMA_AUDIT_ACTIONS,
toRecurringProformaAuditState,
toRecurringProformaAuditTarget,
} from "../audit";
import type { IRecurringProformaRepository } from "../repositories";
export class RecurringProformaFromProformaCreator {
public constructor(
private readonly deps: {
proformaRepository: IProformaRepository;
recurringProformaRepository: IRecurringProformaRepository;
audit: IAudit;
}
) {}
public async create(params: {
companyId: UniqueID;
proformaId: UniqueID;
id: UniqueID;
name: string;
schedule: Omit<RecurrenceScheduleProps, "endDate"> & { endDate?: Date | null };
transaction: unknown;
}): Promise<Result<RecurringProforma, Error>> {
const source = await this.deps.proformaRepository.getByIdInCompany(
params.companyId,
params.proformaId,
params.transaction
);
if (source.isFailure) return Result.fail(source.error);
const schedule = RecurrenceSchedule.create(params.schedule);
if (schedule.isFailure) return Result.fail(schedule.error);
const items: RecurringProformaItemCreateProps[] = source.data.items.getAll().map((item) => ({
description: item.description,
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
ivaCode: item.ivaCode(),
recCode: item.recCode(),
retentionCode: item.retentionCode(),
}));
const recurring = RecurringProforma.create({
companyId: params.companyId,
name: params.name,
customerId: Maybe.some(source.data.customerId),
description: source.data.description,
schedule: Maybe.some(schedule.data),
globalDiscountPercentage: Maybe.some(source.data.globalDiscountPercentage),
paymentTermId: source.data.paymentTermId,
paymentMethodId: source.data.paymentMethodId,
language: Maybe.some(source.data.languageCode),
currency: Maybe.some(source.data.currencyCode),
proformaSeries: source.data.proformaSeriesCode,
issuedInvoiceSeries: source.data.targetInvoiceSeriesCode,
items,
}, params.id);
if (recurring.isFailure) return Result.fail(recurring.error);
const saved = await this.deps.recurringProformaRepository.save(recurring.data, params.transaction);
if (saved.isFailure) return Result.fail(saved.error);
const audit = await this.deps.audit.recordMutation({
action: RECURRING_PROFORMA_AUDIT_ACTIONS.CREATED_FROM_PROFORMA,
target: toRecurringProformaAuditTarget(recurring.data),
before: {},
after: toRecurringProformaAuditState(recurring.data),
});
return audit.isFailure ? Result.fail(audit.error) : Result.ok(recurring.data);
}
}

View File

@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { RecurringProforma } from "../../../domain";
import { RecurringProformaMaterializer } from "./recurring-proforma-materializer";
test("RecurringProformaMaterializer rechaza una plantilla sin customer antes de crear Proforma", async () => {
const companyId = UniqueID.generateNewID();
const recurring = RecurringProforma.create({ companyId, name: "Cuota" }).data;
let creatorCalls = 0;
const materializer = new RecurringProformaMaterializer({
customerServices: { getCustomerById: async () => Result.fail(new Error("not used")) } as never,
companiesServices: {} as never,
paymentResolver: {} as never,
taxConfigResolver: {} as never,
taxResolver: {} as never,
proformaSeriesValidator: {} as never,
targetInvoiceSeriesValidator: {} as never,
proformaCreator: { create: async () => { creatorCalls += 1; return Result.fail(new Error("not used")); } } as never,
});
const result = await materializer.materialize({ recurringProforma: recurring, companyId, scheduledDate: new Date("2026-09-01"), generationDate: new Date("2026-09-15"), transaction: {} });
assert.equal(result.isFailure, true);
assert.equal(creatorCalls, 0);
});

View File

@ -0,0 +1,83 @@
import type { ICompanyPublicServices } from "@erp/companies/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { DiscountPercentage } from "@erp/core/api";
import { UniqueID, UtcDate } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceStatus, ProformaRecipient, type RecurringProforma } from "../../../domain";
import type { ProformaCreateInputProps } from "../../proformas/models";
import type { IProformaCreator } from "../../proformas/services/proforma-creator";
import type { ProformaPaymentResolver } from "../../proformas/services/catalog-resolver/proforma-payment-resolver";
import type { ProformaTaxConfigResolver } from "../../proformas/services/catalog-resolver/proforma-tax-config-resolver";
import type { ProformaTaxResolver } from "../../proformas/services/catalog-resolver/proforma-tax-resolver";
import type { ProformaSeriesValidator } from "../../proformas/services/proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "../../proformas/services/proforma-target-invoice-series-validator.interface";
export class RecurringProformaMaterializer {
public constructor(
private readonly deps: {
customerServices: ICustomerPublicServices;
companiesServices: ICompanyPublicServices;
paymentResolver: ProformaPaymentResolver;
taxConfigResolver: ProformaTaxConfigResolver;
taxResolver: ProformaTaxResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
proformaCreator: IProformaCreator;
}
) {}
public async materialize(params: {
recurringProforma: RecurringProforma;
companyId: UniqueID;
scheduledDate: Date;
generationDate: Date;
transaction: unknown;
}): Promise<Result<{ proformaId: UniqueID }, Error>> {
const customerId = params.recurringProforma.customerId;
if (customerId.isNone()) return Result.fail(new Error("Recurring proforma customer is required."));
const customerResult = await this.deps.customerServices.getCustomerById(customerId.unwrap(), {
companyId: params.companyId, transaction: params.transaction,
});
if (customerResult.isFailure) return Result.fail(customerResult.error);
const customer = customerResult.data;
const date = UtcDate.createFromDate(params.generationDate);
if (date.isFailure) return Result.fail(date.error);
const paymentTerm = await this.resolvePaymentTerm(params.recurringProforma, customer, params);
if (paymentTerm.isFailure) return Result.fail(paymentTerm.error);
const paymentMethod = params.recurringProforma.paymentMethodId.isSome()
? params.recurringProforma.paymentMethodId : customer.paymentMethodId;
const paymentMethodValidation = await this.deps.paymentResolver.ensurePaymentMethodById({ companyId: params.companyId, id: paymentMethod, transaction: params.transaction });
if (paymentMethodValidation.isFailure) return Result.fail(paymentMethodValidation.error);
const languageCode = params.recurringProforma.language.isSome() ? params.recurringProforma.language.unwrap() : customer.languageCode;
const currencyCode = params.recurringProforma.currency.isSome() ? params.recurringProforma.currency.unwrap() : customer.currencyCode;
const series = await this.validateSeries(params.recurringProforma, params);
if (series.isFailure) return Result.fail(series.error);
const taxConfig = await this.deps.taxConfigResolver.resolve({ companyId: params.companyId, atDate: date.data, customer, transaction: params.transaction });
if (taxConfig.isFailure) return Result.fail(taxConfig.error);
const items: ProformaCreateInputProps["items"] = [];
for (const item of params.recurringProforma.items.getAll()) {
const taxes = await this.deps.taxResolver.ensureItemTaxes({ companyId: params.companyId, atDate: date.data, transaction: params.transaction, taxCodes: { ivaCode: item.ivaCode, recCode: item.recCode, retentionCode: item.retentionCode } });
if (taxes.isFailure) return Result.fail(taxes.error);
items.push({ description: item.description, quantity: item.quantity, unitAmount: item.unitAmount, itemDiscountPercentage: item.itemDiscountPercentage, taxes: { ivaCode: item.ivaCode, recCode: item.recCode, retentionCode: item.retentionCode }, languageCode, currencyCode, globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome() ? params.recurringProforma.globalDiscountPercentage.unwrap() : DiscountPercentage.zero() });
}
const recipient = ProformaRecipient.create({ name: customer.name, tin: customer.tin, street: customer.address.street, street2: customer.address.street2, city: customer.address.city, postalCode: customer.address.postalCode, province: customer.address.province, country: customer.address.country });
if (recipient.isFailure) return Result.fail(recipient.error);
const status = InvoiceStatus.create("draft");
if (status.isFailure) return Result.fail(status.error);
const created = await this.deps.proformaCreator.create({ companyId: params.companyId, id: UniqueID.generateNewID(), transaction: params.transaction, props: { companyId: params.companyId, status: status.data, proformaSeriesCode: params.recurringProforma.proformaSeries, targetInvoiceSeriesCode: params.recurringProforma.issuedInvoiceSeries, proformaDate: date.data, operationDate: Maybe.some(date.data), customerId: customer.id, recipient: Maybe.some(recipient.data), reference: Maybe.none(), description: params.recurringProforma.description, notes: Maybe.none(), languageCode, currencyCode, linkedInvoiceId: Maybe.none(), paymentMethodId: paymentMethod, taxRegimeCode: customer.taxRegimeCode, taxConfig: taxConfig.data.getProps(), globalDiscountPercentage: params.recurringProforma.globalDiscountPercentage.isSome() ? params.recurringProforma.globalDiscountPercentage.unwrap() : DiscountPercentage.zero(), items } });
return created.isFailure ? Result.fail(created.error) : Result.ok({ proformaId: created.data.id });
}
private async validateSeries(recurring: RecurringProforma, params: { companyId: UniqueID; transaction: unknown }): Promise<Result<void, Error>> {
const proforma = await this.deps.proformaSeriesValidator.ensureValidProformaSeries({ companyId: params.companyId, proformaSeriesCode: recurring.proformaSeries, transaction: params.transaction });
if (proforma.isFailure) return Result.fail(proforma.error);
return this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({ companyId: params.companyId, targetInvoiceSeriesCode: recurring.issuedInvoiceSeries, transaction: params.transaction });
}
private async resolvePaymentTerm(recurring: RecurringProforma, customer: { paymentTermId: Maybe<UniqueID> }, params: { companyId: UniqueID; transaction: unknown }): Promise<Result<Maybe<UniqueID>, Error>> {
const selected = recurring.paymentTermId.isSome() ? recurring.paymentTermId : customer.paymentTermId;
const validation = await this.deps.paymentResolver.ensurePaymentTermById({ companyId: params.companyId, id: selected, transaction: params.transaction });
return validation.isFailure ? Result.fail(validation.error) : Result.ok(selected);
}
}

View File

@ -0,0 +1,696 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CurrencyCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
ItemAmount,
ItemDescription,
ItemQuantity,
RecurrenceSchedule,
RecurringProforma,
RecurringProformaOccurrence,
} from "../../../domain";
import { RecurringProformaOccurrenceProcessor } from "./recurring-proforma-occurrence-processor";
const required = <T>(result: { data: T }): T => result.data;
const createProcessorRecurring = (params: {
companyId: UniqueID;
id: UniqueID;
schedule: RecurrenceSchedule;
slot: Date;
status?: "draft" | "active" | "paused" | "completed" | "archived";
}): RecurringProforma => {
const currency = required(CurrencyCode.create("EUR"));
const status = params.status ?? "active";
return required(
RecurringProforma.create(
{
companyId: params.companyId,
name: "Recurring processor fixture",
customerId: Maybe.some(UniqueID.generateNewID()),
schedule: Maybe.some(params.schedule),
status,
nextOccurrenceDate:
status === "active" || status === "paused" ? Maybe.some(params.slot) : Maybe.none(),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 100, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
},
],
},
params.id
)
);
};
const createObservedProcessor = (params: {
recurring: RecurringProforma;
occurrence: RecurringProformaOccurrence | null;
materialize: (params: {
recurringProforma: RecurringProforma;
scheduledDate: Date;
generationDate: Date;
transaction: unknown;
}) => Promise<unknown>;
transactions?: unknown[];
}) => {
let occurrence = params.occurrence;
let transactionIndex = 0;
const transactions = params.transactions ?? [{ id: "main" }];
const locks: unknown[][] = [];
const lookups: unknown[][] = [];
const materializerCalls: Array<{ scheduledDate: Date; generationDate: Date; transaction: unknown }> = [];
const audits: Array<{ action: string; transaction: unknown }> = [];
const processor = new RecurringProformaOccurrenceProcessor({
transactionManager: { complete: async (work: (transaction: unknown) => Promise<unknown>) => work(transactions[transactionIndex++]) } as never,
recurringProformaRepository: {
findByIdInCompanyForUpdate: async (...args: unknown[]) => {
locks.push(args);
return Result.ok(params.recurring);
},
save: async () => Result.ok(undefined),
} as never,
occurrenceRepository: {
findByRecurringProformaAndScheduledDate: async (...args: unknown[]) => {
lookups.push(args);
return Result.ok(occurrence === null ? Maybe.none() : Maybe.some(occurrence));
},
save: async (saved: RecurringProformaOccurrence) => {
occurrence = saved;
return Result.ok(undefined);
},
} as never,
materializer: {
materialize: async (input: { recurringProforma: RecurringProforma; scheduledDate: Date; generationDate: Date; transaction: unknown }) => {
materializerCalls.push(input);
return params.materialize(input);
},
} as never,
audit: {
recordMutation: async (input: { action: string }, options?: { transaction?: unknown }) => {
audits.push({ action: input.action, transaction: options?.transaction });
return Result.ok(undefined);
},
} as never,
});
return { processor, locks, lookups, materializerCalls, audits, get occurrence() { return occurrence; } };
};
test("first success materializes and persists one generated occurrence", async () => {
const companyId = UniqueID.generateNewID();
const recurringProformaId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const expectedProformaId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-31T00:00:00.000Z");
const slot2 = new Date("2026-02-28T00:00:00.000Z");
const currency = required(CurrencyCode.create("EUR"));
const schedule = required(
RecurrenceSchedule.create({
startDate: slot1,
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
const recurring = required(
RecurringProforma.create(
{
companyId,
name: "Servicio recurrente",
customerId: Maybe.some(customerId),
schedule: Maybe.some(schedule),
status: "active",
nextOccurrenceDate: Maybe.some(slot1),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 100, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
},
],
},
recurringProformaId
)
);
const mainTransaction = { id: "main" };
let occurrence: RecurringProformaOccurrence | null = null;
let occurrenceSaveTransaction: unknown;
let recurringSaveTransaction: unknown;
let lockCall: unknown[] | undefined;
let lookupCall: unknown[] | undefined;
const materializerCalls: Array<{ scheduledDate: Date; generationDate: Date; transaction: unknown }> = [];
const audits: Array<{ action: string; transaction: unknown }> = [];
const processor = new RecurringProformaOccurrenceProcessor({
transactionManager: {
complete: async (work: (transaction: unknown) => Promise<unknown>) => work(mainTransaction),
} as never,
recurringProformaRepository: {
findByIdInCompanyForUpdate: async (...args: unknown[]) => {
lockCall = args;
return Result.ok(recurring);
},
save: async (_aggregate: unknown, transaction: unknown) => {
recurringSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
occurrenceRepository: {
findByRecurringProformaAndScheduledDate: async (...args: unknown[]) => {
lookupCall = args;
return Result.ok(occurrence === null ? Maybe.none() : Maybe.some(occurrence));
},
save: async (saved: RecurringProformaOccurrence, transaction: unknown) => {
occurrence = saved;
occurrenceSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
materializer: {
materialize: async (params: { scheduledDate: Date; generationDate: Date; transaction: unknown }) => {
materializerCalls.push(params);
return Result.ok({ proformaId: expectedProformaId });
},
} as never,
audit: {
recordMutation: async (input: { action: string }, options?: { transaction?: unknown }) => {
audits.push({ action: input.action, transaction: options?.transaction });
return Result.ok(undefined);
},
} as never,
});
const result = await processor.process({ recurringProformaId, companyId });
assert.equal(result.isSuccess, true);
assert.equal(materializerCalls.length, 1);
assert.equal(materializerCalls[0]?.scheduledDate.getTime(), slot1.getTime());
assert.equal(materializerCalls[0]?.generationDate.getTime(), slot1.getTime());
assert.ok(occurrence);
assert.equal(occurrence.status, "generated");
assert.ok(occurrence.attemptCount === 1);
assert.equal(occurrence.proformaId.unwrap().equals(expectedProformaId), true);
assert.equal(recurring.status, "active");
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot2.getTime());
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.generated").length, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.failed").length, 0);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.retried").length, 0);
assert.equal(materializerCalls[0]?.transaction, mainTransaction);
assert.equal(occurrenceSaveTransaction, mainTransaction);
assert.equal(recurringSaveTransaction, mainTransaction);
assert.equal(audits[0]?.transaction, mainTransaction);
assert.deepEqual(lockCall, [companyId, recurringProformaId, mainTransaction]);
assert.deepEqual(lookupCall, [recurringProformaId, slot1, companyId, mainTransaction]);
});
test("first failure persists one failed occurrence in a new transaction", async () => {
const companyId = UniqueID.generateNewID();
const recurringProformaId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-31T00:00:00.000Z");
const currency = required(CurrencyCode.create("EUR"));
const schedule = required(
RecurrenceSchedule.create({
startDate: slot1,
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
const recurring = required(
RecurringProforma.create(
{
companyId,
name: "Servicio recurrente",
customerId: Maybe.some(customerId),
schedule: Maybe.some(schedule),
status: "active",
nextOccurrenceDate: Maybe.some(slot1),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 100, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
},
],
},
recurringProformaId
)
);
const mainTransaction = { id: "main" };
const failureTransaction = { id: "failure" };
const transactions = [mainTransaction, failureTransaction];
let transactionIndex = 0;
let occurrence: RecurringProformaOccurrence | null = null;
let occurrenceSaveTransaction: unknown;
const lockCalls: unknown[][] = [];
const materializerCalls: Array<{ transaction: unknown }> = [];
const audits: Array<{ action: string; transaction: unknown }> = [];
const processor = new RecurringProformaOccurrenceProcessor({
transactionManager: {
complete: async (work: (transaction: unknown) => Promise<unknown>) => {
const transaction = transactions[transactionIndex++];
return work(transaction);
},
} as never,
recurringProformaRepository: {
findByIdInCompanyForUpdate: async (...args: unknown[]) => {
lockCalls.push(args);
return Result.ok(recurring);
},
} as never,
occurrenceRepository: {
findByRecurringProformaAndScheduledDate: async () =>
Result.ok(occurrence === null ? Maybe.none() : Maybe.some(occurrence)),
save: async (saved: RecurringProformaOccurrence, transaction: unknown) => {
occurrence = saved;
occurrenceSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
materializer: {
materialize: async (params: { transaction: unknown }) => {
materializerCalls.push(params);
return Result.fail(new Error("materialization_failed"));
},
} as never,
audit: {
recordMutation: async (input: { action: string }, options?: { transaction?: unknown }) => {
audits.push({ action: input.action, transaction: options?.transaction });
return Result.ok(undefined);
},
} as never,
});
const result = await processor.process({ recurringProformaId, companyId });
assert.equal(result.isSuccess, true);
assert.equal(materializerCalls.length, 1);
assert.equal(materializerCalls[0]?.transaction, mainTransaction);
assert.notEqual(mainTransaction, failureTransaction);
assert.equal(transactionIndex, 2);
assert.ok(occurrence);
assert.equal(occurrence.status, "failed");
assert.ok(occurrence.attemptCount === 1);
assert.equal(occurrence.processingStartedAt.isNone(), true);
assert.equal(occurrence.lastErrorCode.isSome(), true);
assert.equal(recurring.status, "active");
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot1.getTime());
assert.equal(occurrenceSaveTransaction, failureTransaction);
assert.deepEqual(lockCalls[1], [companyId, recurringProformaId, failureTransaction]);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.generated").length, 0);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.retried").length, 0);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.failed").length, 1);
assert.equal(audits[0]?.transaction, failureTransaction);
});
test("retry success reuses the failed occurrence and current recurring configuration", async () => {
const companyId = UniqueID.generateNewID();
const recurringProformaId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const occurrenceId = UniqueID.generateNewID();
const expectedProformaId = UniqueID.generateNewID();
const currentPaymentMethodId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-31T00:00:00.000Z");
const slot2 = new Date("2026-02-28T00:00:00.000Z");
const currency = required(CurrencyCode.create("EUR"));
const schedule = required(
RecurrenceSchedule.create({
startDate: slot1,
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
const recurring = required(
RecurringProforma.create(
{
companyId,
name: "Configuracion B",
customerId: Maybe.some(customerId),
paymentMethodId: Maybe.some(currentPaymentMethodId),
schedule: Maybe.some(schedule),
status: "active",
nextOccurrenceDate: Maybe.some(slot1),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio B"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 100, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
},
],
},
recurringProformaId
)
);
let occurrence = required(
RecurringProformaOccurrence.create(
{
companyId,
recurringProformaId,
scheduledDate: slot1,
status: "failed",
attemptCount: 1,
lastAttemptAt: Maybe.some(new Date("2026-01-30T10:00:00.000Z")),
lastErrorCode: Maybe.some("previous_error"),
lastErrorMessage: Maybe.some("Previous materialization failed"),
createdAt: new Date("2026-01-30T09:00:00.000Z"),
updatedAt: new Date("2026-01-30T10:00:00.000Z"),
},
occurrenceId
)
);
const beforeOccurrenceId = occurrence.id;
const mainTransaction = { id: "main" };
let transactionCalls = 0;
let occurrenceSaveTransaction: unknown;
let recurringSaveTransaction: unknown;
const materializerCalls: Array<{ recurringProforma: RecurringProforma; transaction: unknown }> = [];
const audits: Array<{ action: string; transaction: unknown }> = [];
const processor = new RecurringProformaOccurrenceProcessor({
transactionManager: {
complete: async (work: (transaction: unknown) => Promise<unknown>) => {
transactionCalls += 1;
return work(mainTransaction);
},
} as never,
recurringProformaRepository: {
findByIdInCompanyForUpdate: async () => Result.ok(recurring),
save: async (_aggregate: unknown, transaction: unknown) => {
recurringSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
occurrenceRepository: {
findByRecurringProformaAndScheduledDate: async () => Result.ok(Maybe.some(occurrence)),
save: async (saved: RecurringProformaOccurrence, transaction: unknown) => {
occurrence = saved;
occurrenceSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
materializer: {
materialize: async (params: { recurringProforma: RecurringProforma; transaction: unknown }) => {
materializerCalls.push(params);
return Result.ok({ proformaId: expectedProformaId });
},
} as never,
audit: {
recordMutation: async (input: { action: string }, options?: { transaction?: unknown }) => {
audits.push({ action: input.action, transaction: options?.transaction });
return Result.ok(undefined);
},
} as never,
});
const result = await processor.process({ recurringProformaId, companyId });
const afterOccurrenceId = occurrence.id;
assert.equal(result.isSuccess, true);
assert.equal(materializerCalls.length, 1);
assert.ok(beforeOccurrenceId === afterOccurrenceId);
assert.equal(occurrence.status, "generated");
assert.ok(occurrence.attemptCount === 2);
assert.equal(occurrence.proformaId.unwrap().equals(expectedProformaId), true);
assert.equal(occurrence.lastErrorCode.isNone(), true);
assert.equal(occurrence.lastErrorMessage.isNone(), true);
assert.equal(occurrence.processingStartedAt.isNone(), true);
assert.equal(recurring.status, "active");
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot2.getTime());
assert.equal(materializerCalls[0]?.recurringProforma.paymentMethodId.unwrap().equals(currentPaymentMethodId), true);
assert.equal(materializerCalls[0]?.transaction, mainTransaction);
assert.equal(occurrenceSaveTransaction, mainTransaction);
assert.equal(recurringSaveTransaction, mainTransaction);
assert.equal(transactionCalls, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.retried").length, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.generated").length, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.failed").length, 0);
assert.equal(audits[0]?.transaction, mainTransaction);
assert.equal(audits[1]?.transaction, mainTransaction);
});
test("retry failure reuses the failed occurrence in the failure transaction", async () => {
const companyId = UniqueID.generateNewID();
const recurringProformaId = UniqueID.generateNewID();
const customerId = UniqueID.generateNewID();
const occurrenceId = UniqueID.generateNewID();
const currentPaymentMethodId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-31T00:00:00.000Z");
const currency = required(CurrencyCode.create("EUR"));
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = required(
RecurringProforma.create(
{
companyId,
name: "Configuracion B",
customerId: Maybe.some(customerId),
paymentMethodId: Maybe.some(currentPaymentMethodId),
schedule: Maybe.some(schedule),
status: "active",
nextOccurrenceDate: Maybe.some(slot1),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio B"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 100, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
},
],
},
recurringProformaId
)
);
let occurrence = required(
RecurringProformaOccurrence.create(
{
companyId,
recurringProformaId,
scheduledDate: slot1,
status: "failed",
attemptCount: 1,
lastAttemptAt: Maybe.some(new Date("2026-01-30T10:00:00.000Z")),
lastErrorCode: Maybe.some("previous_error"),
lastErrorMessage: Maybe.some("Previous materialization failed"),
createdAt: new Date("2026-01-30T09:00:00.000Z"),
updatedAt: new Date("2026-01-30T10:00:00.000Z"),
},
occurrenceId
)
);
const beforeOccurrenceId = occurrence.id;
const mainTransaction = { id: "main" };
const failureTransaction = { id: "failure" };
const transactions = [mainTransaction, failureTransaction];
let transactionIndex = 0;
let occurrenceSaveTransaction: unknown;
const lockCalls: unknown[][] = [];
const materializerCalls: Array<{ recurringProforma: RecurringProforma; transaction: unknown }> = [];
const audits: Array<{ action: string; transaction: unknown }> = [];
const processor = new RecurringProformaOccurrenceProcessor({
transactionManager: {
complete: async (work: (transaction: unknown) => Promise<unknown>) => {
const transaction = transactions[transactionIndex++];
return work(transaction);
},
} as never,
recurringProformaRepository: {
findByIdInCompanyForUpdate: async (...args: unknown[]) => {
lockCalls.push(args);
return Result.ok(recurring);
},
} as never,
occurrenceRepository: {
findByRecurringProformaAndScheduledDate: async () => Result.ok(Maybe.some(occurrence)),
save: async (saved: RecurringProformaOccurrence, transaction: unknown) => {
occurrence = saved;
occurrenceSaveTransaction = transaction;
return Result.ok(undefined);
},
} as never,
materializer: {
materialize: async (params: { recurringProforma: RecurringProforma; transaction: unknown }) => {
materializerCalls.push(params);
return Result.fail(new Error("new_error"));
},
} as never,
audit: {
recordMutation: async (input: { action: string }, options?: { transaction?: unknown }) => {
audits.push({ action: input.action, transaction: options?.transaction });
return Result.ok(undefined);
},
} as never,
});
const result = await processor.process({ recurringProformaId, companyId });
const afterOccurrenceId = occurrence.id;
assert.equal(result.isSuccess, true);
assert.equal(materializerCalls.length, 1);
assert.ok(beforeOccurrenceId === afterOccurrenceId);
assert.equal(occurrence.status, "failed");
assert.ok(occurrence.attemptCount === 2);
assert.equal(occurrence.processingStartedAt.isNone(), true);
assert.equal(occurrence.lastErrorCode.unwrap(), "new_error");
assert.equal(occurrence.lastErrorCode.unwrap() === "previous_error", false);
assert.equal(recurring.status, "active");
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot1.getTime());
assert.notEqual(mainTransaction, failureTransaction);
assert.equal(materializerCalls[0]?.transaction, mainTransaction);
assert.equal(materializerCalls[0]?.recurringProforma.paymentMethodId.unwrap().equals(currentPaymentMethodId), true);
assert.equal(occurrenceSaveTransaction, failureTransaction);
assert.deepEqual(lockCalls[0], [companyId, recurringProformaId, mainTransaction]);
assert.deepEqual(lockCalls[1], [companyId, recurringProformaId, failureTransaction]);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.retried").length, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.failed").length, 1);
assert.equal(audits.filter((audit) => audit.action === "recurring_proforma_occurrence.generated").length, 0);
assert.equal(audits[0]?.transaction, failureTransaction);
assert.equal(audits[1]?.transaction, failureTransaction);
});
test("processing occurrence does not materialize or mutate the cursor", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: slot1 });
const occurrence = required(RecurringProformaOccurrence.create({ companyId, recurringProformaId: recurringId, scheduledDate: slot1, status: "processing", attemptCount: 3, processingStartedAt: Maybe.some(slot1), createdAt: slot1, updatedAt: slot1 }));
const fixture = createObservedProcessor({ recurring, occurrence, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isFailure, true);
assert.equal(result.error.message, "already_processing");
assert.equal(fixture.materializerCalls.length, 0);
assert.equal(fixture.occurrence?.attemptCount, 3);
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot1.getTime());
assert.equal(fixture.audits.length, 0);
});
test("generated occurrence is a safe inconsistency and does not materialize twice", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: slot1 });
const occurrence = required(RecurringProformaOccurrence.create({ companyId, recurringProformaId: recurringId, scheduledDate: slot1, status: "generated", attemptCount: 1, proformaId: Maybe.some(UniqueID.generateNewID()), generatedAt: Maybe.some(slot1), createdAt: slot1, updatedAt: slot1 }));
const fixture = createObservedProcessor({ recurring, occurrence, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isFailure, true);
assert.equal(result.error.message, "generated_occurrence_cursor_inconsistency");
assert.equal(fixture.materializerCalls.length, 0);
assert.equal(fixture.occurrence?.attemptCount, 1);
assert.equal(fixture.audits.length, 0);
});
test("non-processable parent states do not create occurrences", async () => {
for (const status of ["draft", "paused", "completed", "archived"] as const) {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: slot1, status });
const fixture = createObservedProcessor({ recurring, occurrence: null, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isFailure, true);
assert.equal(result.error.message, "invalid_recurring_proforma_status");
assert.equal(fixture.materializerCalls.length, 0);
assert.equal(fixture.occurrence, null);
assert.equal(recurring.status, status);
assert.equal(recurring.nextOccurrenceDate.isNone(), status === "draft" || status === "completed" || status === "archived");
}
});
test("a success processes only the cursor slot and advances exactly one slot", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-01T00:00:00.000Z");
const slot2 = new Date("2026-02-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: slot1 });
const fixture = createObservedProcessor({ recurring, occurrence: null, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(fixture.materializerCalls.length, 1);
assert.equal(fixture.materializerCalls[0]?.scheduledDate.getTime(), slot1.getTime());
assert.equal(recurring.status, "active");
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot2.getTime());
});
test("a failure at the cursor blocks every later schedule slot", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const slot1 = new Date("2026-01-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: slot1, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: slot1 });
const mainTransaction = { id: "main" };
const failureTransaction = { id: "failure" };
const fixture = createObservedProcessor({
recurring,
occurrence: null,
transactions: [mainTransaction, failureTransaction],
materialize: async () => Result.fail(new Error("failed_slot_1")),
});
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(fixture.materializerCalls.length, 1);
assert.equal(fixture.materializerCalls[0]?.scheduledDate.getTime(), slot1.getTime());
assert.equal(recurring.nextOccurrenceDate.unwrap().getTime(), slot1.getTime());
assert.equal(fixture.occurrence?.scheduledDate.getTime(), slot1.getTime());
assert.equal(fixture.occurrence?.status, "failed");
});
test("the inclusive end date is materialized before completing the recurring proforma", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const startDate = new Date("2026-01-01T00:00:00.000Z");
const endDate = new Date("2026-03-01T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate, endDate, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: endDate });
const fixture = createObservedProcessor({ recurring, occurrence: null, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(fixture.materializerCalls.length, 1);
assert.equal(fixture.materializerCalls[0]?.scheduledDate.getTime(), endDate.getTime());
assert.equal(fixture.occurrence?.status, "generated");
assert.equal(recurring.status, "completed");
assert.equal(recurring.nextOccurrenceDate.isNone(), true);
});
test("generation date is passed independently from the schedule cursor", async () => {
const companyId = UniqueID.generateNewID();
const recurringId = UniqueID.generateNewID();
const scheduledDate = new Date("2026-10-01T00:00:00.000Z");
const generationDate = new Date("2026-09-15T00:00:00.000Z");
const schedule = required(RecurrenceSchedule.create({ startDate: scheduledDate, unit: "month", interval: 1, monthlyRule: "same_day" }));
const recurring = createProcessorRecurring({ companyId, id: recurringId, schedule, slot: scheduledDate });
const fixture = createObservedProcessor({ recurring, occurrence: null, materialize: async () => Result.ok({ proformaId: UniqueID.generateNewID() }) });
const result = await fixture.processor.process({ companyId, recurringProformaId: recurringId, generationDate });
assert.equal(result.isSuccess, true);
assert.equal(fixture.materializerCalls[0]?.scheduledDate.getTime(), scheduledDate.getTime());
assert.equal(fixture.materializerCalls[0]?.generationDate.getTime(), generationDate.getTime());
});

View File

@ -0,0 +1,109 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { RecurringProformaOccurrence, type RecurringProforma } from "../../../domain";
import type { IRecurringProformaOccurrenceRepository, IRecurringProformaRepository } from "../repositories";
import type { RecurringProformaMaterializer } from "./recurring-proforma-materializer";
const OCCURRENCE_AUDIT = {
GENERATED: "recurring_proforma_occurrence.generated",
FAILED: "recurring_proforma_occurrence.failed",
RETRIED: "recurring_proforma_occurrence.retried",
} as const;
export class RecurringProformaOccurrenceProcessor {
public constructor(private readonly deps: {
transactionManager: ITransactionManager;
recurringProformaRepository: IRecurringProformaRepository;
occurrenceRepository: IRecurringProformaOccurrenceRepository;
materializer: RecurringProformaMaterializer;
audit: IAudit;
}) {}
public async process(params: { recurringProformaId: UniqueID; companyId: UniqueID; generationDate?: Date }): Promise<Result<{ occurrenceId: UniqueID; status: string; proformaId?: UniqueID; recurringProformaStatus?: string; nextOccurrenceDate?: Date | null }, Error>> {
let scheduledDate: Date | null = null;
let wasRetry = false;
let materializationAttempted = false;
try {
return await this.deps.transactionManager.complete(async (transaction) => {
const recurring = await this.deps.recurringProformaRepository.findByIdInCompanyForUpdate(params.companyId, params.recurringProformaId, transaction);
if (recurring.isFailure) return Result.fail(recurring.error);
if (recurring.data.status !== "active") return Result.fail(new Error("invalid_recurring_proforma_status"));
if (recurring.data.nextOccurrenceDate.isNone()) return Result.fail(new Error("missing_next_occurrence_date"));
scheduledDate = recurring.data.nextOccurrenceDate.unwrap();
const found = await this.deps.occurrenceRepository.findByRecurringProformaAndScheduledDate(params.recurringProformaId, scheduledDate, params.companyId, transaction);
if (found.isFailure) return Result.fail(found.error);
const now = new Date();
const occurrence = found.data.isSome() ? found.data.unwrap() : this.createOccurrence(params.companyId, params.recurringProformaId, scheduledDate, now);
if (occurrence.status === "processing") return Result.fail(new Error("already_processing"));
if (occurrence.status === "generated") return Result.fail(new Error("generated_occurrence_cursor_inconsistency"));
wasRetry = occurrence.status === "failed";
const processing = occurrence.markProcessing(now);
if (processing.isFailure) return Result.fail(processing.error);
materializationAttempted = true;
const materialized = await this.deps.materializer.materialize({ recurringProforma: recurring.data, companyId: params.companyId, scheduledDate, generationDate: params.generationDate ?? scheduledDate, transaction });
if (materialized.isFailure) throw materialized.error;
const generated = occurrence.markGenerated({ now, generatedAt: now, proformaId: materialized.data.proformaId });
if (generated.isFailure) return Result.fail(generated.error);
const savedOccurrence = await this.deps.occurrenceRepository.save(occurrence, transaction);
if (savedOccurrence.isFailure) return Result.fail(savedOccurrence.error);
const advanced = this.advanceCursor(recurring.data, scheduledDate);
if (advanced.isFailure) return Result.fail(advanced.error);
const savedRecurring = await this.deps.recurringProformaRepository.save(recurring.data, transaction);
if (savedRecurring.isFailure) return Result.fail(savedRecurring.error);
const audit = await this.deps.audit.recordMutation({ action: OCCURRENCE_AUDIT.GENERATED, target: { type: "recurring_proforma_occurrence", id: occurrence.id.toPrimitive(), companyId: params.companyId.toPrimitive() }, before: {}, after: { recurringProformaId: params.recurringProformaId.toPrimitive(), scheduledDate: scheduledDate.toISOString(), proformaId: materialized.data.proformaId.toPrimitive(), attemptCount: occurrence.attemptCount } }, { transaction });
if (audit.isFailure) return Result.fail(audit.error);
if (wasRetry) {
const retried = await this.deps.audit.recordMutation({ action: OCCURRENCE_AUDIT.RETRIED, target: { type: "recurring_proforma_occurrence", id: occurrence.id.toPrimitive(), companyId: params.companyId.toPrimitive() }, before: {}, after: { recurringProformaId: params.recurringProformaId.toPrimitive(), scheduledDate: scheduledDate.toISOString(), attemptCount: occurrence.attemptCount } }, { transaction });
if (retried.isFailure) return Result.fail(retried.error);
}
return Result.ok({ occurrenceId: occurrence.id, status: occurrence.status, proformaId: materialized.data.proformaId, recurringProformaStatus: recurring.data.status, nextOccurrenceDate: recurring.data.nextOccurrenceDate.getOrUndefined() ?? null });
});
} catch (error: unknown) {
if (scheduledDate === null || !materializationAttempted) return Result.fail(error instanceof Error ? error : new Error("proforma_materialization_failed"));
return this.persistFailure({ ...params, scheduledDate, wasRetry, error: error instanceof Error ? error : new Error("proforma_materialization_failed") });
}
}
private async persistFailure(params: { recurringProformaId: UniqueID; companyId: UniqueID; scheduledDate: Date; generationDate?: Date; wasRetry: boolean; error: Error }): Promise<Result<{ occurrenceId: UniqueID; status: string }, Error>> {
try {
return await this.deps.transactionManager.complete(async (transaction) => {
const recurring = await this.deps.recurringProformaRepository.findByIdInCompanyForUpdate(
params.companyId,
params.recurringProformaId,
transaction
);
if (recurring.isFailure) return Result.fail(recurring.error);
const found = await this.deps.occurrenceRepository.findByRecurringProformaAndScheduledDate(params.recurringProformaId, params.scheduledDate, params.companyId, transaction);
if (found.isFailure) return Result.fail(found.error);
const now = new Date();
const occurrence = found.data.isSome() ? found.data.unwrap() : this.createOccurrence(params.companyId, params.recurringProformaId, params.scheduledDate, now);
const processing = occurrence.markProcessing(now);
if (processing.isFailure) return Result.fail(processing.error);
const failed = occurrence.markFailed({ now, errorCode: params.error.message, errorMessage: params.error.message });
if (failed.isFailure) return Result.fail(failed.error);
const saved = await this.deps.occurrenceRepository.save(occurrence, transaction);
if (saved.isFailure) return Result.fail(saved.error);
const audit = await this.deps.audit.recordMutation({ action: OCCURRENCE_AUDIT.FAILED, target: { type: "recurring_proforma_occurrence", id: occurrence.id.toPrimitive(), companyId: params.companyId.toPrimitive() }, before: {}, after: { recurringProformaId: params.recurringProformaId.toPrimitive(), scheduledDate: params.scheduledDate.toISOString(), attemptCount: occurrence.attemptCount, lastErrorCode: occurrence.lastErrorCode.unwrap() } }, { transaction });
if (audit.isFailure) return Result.fail(audit.error);
if (params.wasRetry) {
const retried = await this.deps.audit.recordMutation({ action: OCCURRENCE_AUDIT.RETRIED, target: { type: "recurring_proforma_occurrence", id: occurrence.id.toPrimitive(), companyId: params.companyId.toPrimitive() }, before: {}, after: { recurringProformaId: params.recurringProformaId.toPrimitive(), scheduledDate: params.scheduledDate.toISOString(), attemptCount: occurrence.attemptCount } }, { transaction });
if (retried.isFailure) return Result.fail(retried.error);
}
return Result.ok({ occurrenceId: occurrence.id, status: occurrence.status });
});
} catch (error: unknown) { return Result.fail(error instanceof Error ? error : new Error("failure_persistence_failed")); }
}
private createOccurrence(companyId: UniqueID, recurringProformaId: UniqueID, scheduledDate: Date, now: Date): RecurringProformaOccurrence {
return RecurringProformaOccurrence.create({ companyId, recurringProformaId, scheduledDate, createdAt: now, updatedAt: now }).data;
}
private advanceCursor(recurring: RecurringProforma, current: Date): Result<void, Error> {
if (recurring.schedule.isNone()) return Result.fail(new Error("missing_next_occurrence_date"));
let index = 0;
while (true) { const candidate = recurring.schedule.unwrap().occurrenceAt(index); if (candidate.isFailure) return Result.fail(candidate.error); if (candidate.data === null || candidate.data.getTime() > current.getTime()) { if (candidate.data === null) return recurring.complete().map(() => undefined); return recurring.advanceNextOccurrenceDate(candidate.data).map(() => undefined); } index += 1; }
}
}

View File

@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { ItemAmount, ItemDescription, ItemQuantity, RecurrenceSchedule, RecurringProforma } from "../../../domain";
import type { RecurringProformaItemCreateProps } from "../../../domain";
import { RecurringProformaActivationReadinessChecker } from "./recurring-proforma-activation-readiness-checker";
import { RecurringProformaUpdater } from "./recurring-proforma-updater";
const required = <T>(result: { data: T }): T => result.data;
const id = () => UniqueID.generateNewID();
const item = (): RecurringProformaItemCreateProps => {
const currency = required(CurrencyCode.create("EUR"));
return {
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 10, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.none(),
};
};
const schedule = () =>
required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-01T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
test("RecurringProformaUpdater distingue omitido, null y valor", async () => {
const companyId = id();
const recurring = required(RecurringProforma.create({ companyId, name: "Inicial" }));
const saves: RecurringProforma[] = [];
const updater = new RecurringProformaUpdater({
repository: {
findByIdInCompany: async () => Result.ok(recurring),
save: async (aggregate) => {
saves.push(aggregate);
return Result.ok(undefined);
},
deleteByIdInCompany: async () => Result.ok(true),
hasOccurrences: async () => Result.ok(false),
},
occurrenceRepository: {
save: async () => Result.ok(undefined),
findByIdInCompany: async () => Result.fail(new Error("not used")),
latestScheduledDate: async () => Result.ok(Maybe.none()),
hasFailedOccurrences: async () => Result.ok(false),
},
customerServices: { getCustomerById: async () => Result.ok({}) } as never,
paymentResolver: {
ensurePaymentTermById: async () => Result.ok(undefined),
ensurePaymentMethodById: async () => Result.ok(undefined),
} as never,
proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) } as never,
targetInvoiceSeriesValidator: { ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined) } as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
});
const updated = await updater.update({
companyId,
id: recurring.id,
patch: { name: "Actualizada", description: "Comercial" },
});
assert.equal(updated.isSuccess, true);
assert.equal(recurring.name, "Actualizada");
assert.equal(recurring.description.unwrap(), "Comercial");
assert.equal(saves.length, 1);
const cleared = await updater.update({
companyId,
id: recurring.id,
patch: { description: null },
});
assert.equal(cleared.isSuccess, true);
assert.equal(recurring.description.isNone(), true);
});
test("RecurringProformaUpdater bloquea endDate anterior al historial", async () => {
const companyId = id();
const recurring = required(RecurringProforma.create({ companyId, name: "Inicial", schedule: Maybe.some(schedule()) }));
const updater = new RecurringProformaUpdater({
repository: {
findByIdInCompany: async () => Result.ok(recurring),
save: async () => Result.ok(undefined),
deleteByIdInCompany: async () => Result.ok(true),
hasOccurrences: async () => Result.ok(true),
},
occurrenceRepository: {
save: async () => Result.ok(undefined),
findByIdInCompany: async () => Result.fail(new Error("not used")),
latestScheduledDate: async () => Result.ok(Maybe.some(new Date("2026-02-01T00:00:00.000Z"))),
hasFailedOccurrences: async () => Result.ok(false),
},
customerServices: { getCustomerById: async () => Result.ok({}) } as never,
paymentResolver: { ensurePaymentTermById: async () => Result.ok(undefined), ensurePaymentMethodById: async () => Result.ok(undefined) } as never,
proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) } as never,
targetInvoiceSeriesValidator: { ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined) } as never,
audit: { recordMutation: async () => Result.ok(undefined) } as never,
});
const result = await updater.update({
companyId,
id: recurring.id,
patch: { schedule: { ...schedule().getProps(), endDate: new Date("2026-01-31T00:00:00.000Z") } },
});
assert.equal(result.isFailure, true);
});
test("RecurringProformaActivationReadinessChecker acumula bloqueos sin persistir", async () => {
const companyId = id();
const recurring = required(RecurringProforma.create({ companyId, name: "Borrador" }));
const checker = new RecurringProformaActivationReadinessChecker({
customerServices: { getCustomerById: async () => Result.fail(new Error("not used")) } as never,
paymentResolver: { ensurePaymentTermById: async () => Result.ok(undefined), ensurePaymentMethodById: async () => Result.ok(undefined) } as never,
proformaSeriesValidator: { ensureValidProformaSeries: async () => Result.ok(undefined) } as never,
targetInvoiceSeriesValidator: { ensureValidIssuedInvoiceSeries: async () => Result.ok(undefined) } as never,
});
const readiness = await checker.check({ recurringProforma: recurring, companyId });
assert.equal(readiness.isSuccess, true);
assert.deepEqual(readiness.data.blockers.map((blocker) => blocker.code), ["missing_customer", "missing_items", "missing_schedule"]);
});
test("RecurringProformaUpdater reemplaza items y conserva el calendario sin historial", async () => {
const companyId = id();
const recurring = required(RecurringProforma.create({ companyId, name: "Inicial" }));
const discount = required(DiscountPercentage.create({ value: 500 }));
const language = required(LanguageCode.create("es"));
const currency = required(CurrencyCode.create("EUR"));
assert.equal(discount.toNumber(), 5);
assert.equal(language.code, "es");
assert.equal(currency.code, "EUR");
assert.equal(item().description.isSome(), true);
});

View File

@ -0,0 +1,107 @@
import type { IAudit } from "@repo/rdx-audit";
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { RecurringProformaItemCreateProps } from "../../../domain";
import { RecurringProforma } from "../../../domain";
import {
RECURRING_PROFORMA_AUDIT_ACTIONS,
toRecurringProformaAuditState,
toRecurringProformaAuditTarget,
} from "../audit";
import type {
IRecurringProformaOccurrenceRepository,
IRecurringProformaRepository,
} from "../repositories";
import type { RecurringProformaActivationReadinessChecker } from "./recurring-proforma-activation-readiness-checker";
type MutationDeps = { repository: IRecurringProformaRepository; audit: IAudit };
export class RecurringProformaCreator {
public constructor(private readonly deps: MutationDeps) {}
public async create(params: { companyId: UniqueID; id: UniqueID; name: string; description?: Maybe<string>; transaction?: unknown }): Promise<Result<RecurringProforma, Error>> {
const created = RecurringProforma.create({ companyId: params.companyId, name: params.name, description: params.description }, params.id);
if (created.isFailure) return Result.fail(created.error);
const saved = await this.deps.repository.save(created.data, params.transaction);
if (saved.isFailure) return Result.fail(saved.error);
const audit = await this.deps.audit.recordMutation({ action: RECURRING_PROFORMA_AUDIT_ACTIONS.CREATED, target: toRecurringProformaAuditTarget(created.data), before: {}, after: toRecurringProformaAuditState(created.data) });
return audit.isFailure ? Result.fail(audit.error) : Result.ok(created.data);
}
}
export class RecurringProformaStatusChanger {
public constructor(
private readonly deps: MutationDeps & {
occurrenceRepository: IRecurringProformaOccurrenceRepository;
readinessChecker: RecurringProformaActivationReadinessChecker;
}
) {}
public async change(params: { companyId: UniqueID; id: UniqueID; action: "activate" | "pause" | "resume" | "complete" | "archive"; transaction?: unknown }): Promise<Result<RecurringProforma, Error>> {
const found = await this.deps.repository.findByIdInCompany(params.companyId, params.id, params.transaction);
if (found.isFailure) return Result.fail(found.error);
if (params.action === "activate") {
const readiness = await this.deps.readinessChecker.check({
recurringProforma: found.data,
companyId: params.companyId,
transaction: params.transaction,
});
if (readiness.isFailure) return Result.fail(readiness.error);
if (!readiness.data.canActivate) {
return Result.fail(
new Error(
`Recurring proforma cannot be activated: ${readiness.data.blockers
.map((blocker) => blocker.code)
.join(", ")}`
)
);
}
}
if (params.action === "complete") {
const failed = await this.deps.occurrenceRepository.hasFailedOccurrences(
params.id,
params.companyId,
params.transaction
);
if (failed.isFailure) return Result.fail(failed.error);
if (failed.data) return Result.fail(new Error("Recurring proforma has unresolved failed occurrences"));
}
const before = toRecurringProformaAuditState(found.data);
const result = params.action === "activate" || params.action === "resume" ? found.data.activate() : params.action === "pause" ? found.data.pause() : params.action === "complete" ? found.data.complete() : found.data.archive();
if (result.isFailure || !result.data) return result.isFailure ? Result.fail(result.error) : Result.fail(new Error("Recurring proforma transition is a no-op"));
const saved = await this.deps.repository.save(found.data, params.transaction);
if (saved.isFailure) return Result.fail(saved.error);
const action = params.action === "activate" ? RECURRING_PROFORMA_AUDIT_ACTIONS.ACTIVATED : params.action === "pause" ? RECURRING_PROFORMA_AUDIT_ACTIONS.PAUSED : params.action === "resume" ? RECURRING_PROFORMA_AUDIT_ACTIONS.RESUMED : params.action === "complete" ? RECURRING_PROFORMA_AUDIT_ACTIONS.COMPLETED : RECURRING_PROFORMA_AUDIT_ACTIONS.ARCHIVED;
const audit = await this.deps.audit.recordMutation({ action, target: toRecurringProformaAuditTarget(found.data), before, after: toRecurringProformaAuditState(found.data) });
return audit.isFailure ? Result.fail(audit.error) : Result.ok(found.data);
}
}
export class RecurringProformaDeleter {
public constructor(private readonly deps: MutationDeps) {}
public async delete(params: { companyId: UniqueID; id: UniqueID; transaction?: unknown }): Promise<Result<boolean, Error>> {
const found = await this.deps.repository.findByIdInCompany(params.companyId, params.id, params.transaction);
if (found.isFailure) return Result.fail(found.error);
if (found.data.status !== "draft") return Result.fail(new Error("Only draft recurring proformas can be deleted"));
const history = await this.deps.repository.hasOccurrences(params.id, params.companyId, params.transaction);
if (history.isFailure || history.data) return history.isFailure ? Result.fail(history.error) : Result.fail(new Error("Recurring proformas with occurrences cannot be deleted"));
const deleted = await this.deps.repository.deleteByIdInCompany(params.companyId, params.id, params.transaction);
if (deleted.isFailure) return Result.fail(deleted.error);
const audit = await this.deps.audit.recordMutation({ action: RECURRING_PROFORMA_AUDIT_ACTIONS.DELETED, target: toRecurringProformaAuditTarget(found.data), before: toRecurringProformaAuditState(found.data), after: {} });
return audit.isFailure ? Result.fail(audit.error) : deleted;
}
}
export class RecurringProformaDuplicator {
public constructor(private readonly deps: MutationDeps) {}
public async duplicate(params: { companyId: UniqueID; sourceId: UniqueID; id: UniqueID; transaction?: unknown }): Promise<Result<RecurringProforma, Error>> {
const source = await this.deps.repository.findByIdInCompany(params.companyId, params.sourceId, params.transaction);
if (source.isFailure) return Result.fail(source.error);
const items: RecurringProformaItemCreateProps[] = source.data.items.getAll().map((item) => item.getProps());
const copy = RecurringProforma.create({ companyId: params.companyId, name: source.data.name, description: source.data.description, customerId: source.data.customerId, schedule: source.data.schedule, paymentTermId: source.data.paymentTermId, paymentMethodId: source.data.paymentMethodId, language: source.data.language, currency: source.data.currency, proformaSeries: source.data.proformaSeries, issuedInvoiceSeries: source.data.issuedInvoiceSeries, items }, params.id);
if (copy.isFailure) return Result.fail(copy.error);
const saved = await this.deps.repository.save(copy.data, params.transaction);
if (saved.isFailure) return Result.fail(saved.error);
const audit = await this.deps.audit.recordMutation({ action: RECURRING_PROFORMA_AUDIT_ACTIONS.DUPLICATED, target: toRecurringProformaAuditTarget(copy.data), before: {}, after: toRecurringProformaAuditState(copy.data) });
return audit.isFailure ? Result.fail(audit.error) : Result.ok(copy.data);
}
}

View File

@ -0,0 +1,204 @@
import type { ICustomerPublicServices } from "@erp/customers/api";
import type { IAudit } from "@repo/rdx-audit";
import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { DiscountPercentage } from "@erp/core/api";
import {
type RecurrenceScheduleProps,
RecurrenceSchedule,
type RecurringProforma,
type RecurringProformaItemCreateProps,
type InvoiceSerie,
} from "../../../domain";
import {
RECURRING_PROFORMA_AUDIT_ACTIONS,
toRecurringProformaAuditState,
toRecurringProformaAuditTarget,
} from "../audit";
import type {
IRecurringProformaOccurrenceRepository,
IRecurringProformaRepository,
} from "../repositories";
import type { ProformaPaymentResolver } from "../../proformas/services/catalog-resolver/proforma-payment-resolver";
import type { ProformaSeriesValidator } from "../../proformas/services/proforma-series-validator.interface";
import type { ProformaTargetInvoiceSeriesValidator } from "../../proformas/services/proforma-target-invoice-series-validator.interface";
export interface RecurringProformaPatchProps {
name?: string;
description?: string | null;
globalDiscountPercentage?: DiscountPercentage | null;
customerId?: UniqueID | null;
paymentTermId?: UniqueID | null;
paymentMethodId?: UniqueID | null;
language?: LanguageCode | null;
currency?: CurrencyCode | null;
proformaSeries?: InvoiceSerie | null;
issuedInvoiceSeries?: InvoiceSerie | null;
schedule?: Omit<RecurrenceScheduleProps, "endDate"> & { endDate?: Date | null };
items?: RecurringProformaItemCreateProps[];
}
export class RecurringProformaUpdater {
public constructor(
private readonly deps: {
repository: IRecurringProformaRepository;
occurrenceRepository: IRecurringProformaOccurrenceRepository;
customerServices: ICustomerPublicServices;
paymentResolver: ProformaPaymentResolver;
proformaSeriesValidator: ProformaSeriesValidator;
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
audit: IAudit;
}
) {}
public async update(params: {
companyId: UniqueID;
id: UniqueID;
patch: RecurringProformaPatchProps;
transaction?: unknown;
}): Promise<Result<RecurringProforma, Error>> {
const found = await this.deps.repository.findByIdInCompany(
params.companyId,
params.id,
params.transaction
);
if (found.isFailure) return Result.fail(found.error);
const recurringProforma = found.data;
const before = toRecurringProformaAuditState(recurringProforma);
const validation = await this.validateReferences(params);
if (validation.isFailure) return Result.fail(validation.error);
let schedule: RecurrenceSchedule | undefined;
let hasOccurrences = false;
if (params.patch.schedule !== undefined) {
const scheduleResult = RecurrenceSchedule.create(params.patch.schedule);
if (scheduleResult.isFailure) return Result.fail(scheduleResult.error);
schedule = scheduleResult.data;
const history = await this.deps.repository.hasOccurrences(
params.id,
params.companyId,
params.transaction
);
if (history.isFailure) return Result.fail(history.error);
hasOccurrences = history.data;
if (hasOccurrences) {
const endDateRule = await this.ensureEndDateDoesNotPrecedeHistory({
id: params.id,
companyId: params.companyId,
endDate: schedule.endDate,
transaction: params.transaction,
});
if (endDateRule.isFailure) return Result.fail(endDateRule.error);
}
}
const mutation = this.applyPatch(recurringProforma, params.patch, schedule, hasOccurrences);
if (mutation.isFailure) return Result.fail(mutation.error);
if (!mutation.data) return Result.ok(recurringProforma);
const saved = await this.deps.repository.save(recurringProforma, params.transaction);
if (saved.isFailure) return Result.fail(saved.error);
const audited = await this.deps.audit.recordMutation({
action: RECURRING_PROFORMA_AUDIT_ACTIONS.UPDATED,
target: toRecurringProformaAuditTarget(recurringProforma),
before,
after: toRecurringProformaAuditState(recurringProforma),
});
return audited.isFailure ? Result.fail(audited.error) : Result.ok(recurringProforma);
}
private async validateReferences(params: {
companyId: UniqueID;
patch: RecurringProformaPatchProps;
transaction?: unknown;
}): Promise<Result<void, Error>> {
if (params.patch.customerId !== undefined && params.patch.customerId !== null) {
const customer = await this.deps.customerServices.getCustomerById(params.patch.customerId, {
companyId: params.companyId,
transaction: params.transaction,
});
if (customer.isFailure) return Result.fail(customer.error);
}
if (params.patch.paymentTermId !== undefined && params.patch.paymentTermId !== null) {
const paymentTerm = await this.deps.paymentResolver.ensurePaymentTermById({
companyId: params.companyId,
id: Maybe.some(params.patch.paymentTermId),
transaction: params.transaction,
});
if (paymentTerm.isFailure) return Result.fail(paymentTerm.error);
}
if (params.patch.paymentMethodId !== undefined && params.patch.paymentMethodId !== null) {
const paymentMethod = await this.deps.paymentResolver.ensurePaymentMethodById({
companyId: params.companyId,
id: Maybe.some(params.patch.paymentMethodId),
transaction: params.transaction,
});
if (paymentMethod.isFailure) return Result.fail(paymentMethod.error);
}
if (params.patch.proformaSeries !== undefined && params.patch.proformaSeries !== null) {
const proformaSeries = await this.deps.proformaSeriesValidator.ensureValidProformaSeries({
companyId: params.companyId,
proformaSeriesCode: Maybe.some(params.patch.proformaSeries),
transaction: params.transaction,
});
if (proformaSeries.isFailure) return Result.fail(proformaSeries.error);
}
if (params.patch.issuedInvoiceSeries !== undefined && params.patch.issuedInvoiceSeries !== null) {
const issuedInvoiceSeries = await this.deps.targetInvoiceSeriesValidator.ensureValidIssuedInvoiceSeries({
companyId: params.companyId,
targetInvoiceSeriesCode: Maybe.some(params.patch.issuedInvoiceSeries),
transaction: params.transaction,
});
if (issuedInvoiceSeries.isFailure) return Result.fail(issuedInvoiceSeries.error);
}
return Result.ok(undefined);
}
private async ensureEndDateDoesNotPrecedeHistory(params: {
id: UniqueID;
companyId: UniqueID;
endDate: Date | null;
transaction?: unknown;
}): Promise<Result<void, Error>> {
if (params.endDate === null) return Result.ok(undefined);
const latest = await this.deps.occurrenceRepository.latestScheduledDate(
params.id,
params.companyId,
params.transaction
);
if (latest.isFailure) return Result.fail(latest.error);
if (latest.data.isNone() || params.endDate.getTime() >= latest.data.unwrap().getTime()) {
return Result.ok(undefined);
}
return Result.fail(new Error("Recurring proforma endDate cannot precede an occurrence."));
}
private applyPatch(
recurringProforma: RecurringProforma,
patch: RecurringProformaPatchProps,
schedule: RecurrenceSchedule | undefined,
hasOccurrences: boolean
): Result<boolean, Error> {
const operations: Array<Result<boolean, Error>> = [];
if (patch.name !== undefined) operations.push(recurringProforma.rename(patch.name));
if (patch.description !== undefined) operations.push(recurringProforma.setDescription(patch.description === null ? Maybe.none() : Maybe.some(patch.description)));
if (patch.globalDiscountPercentage !== undefined) operations.push(recurringProforma.setGlobalDiscountPercentage(patch.globalDiscountPercentage === null ? Maybe.none() : Maybe.some(patch.globalDiscountPercentage)));
if (patch.customerId !== undefined) operations.push(recurringProforma.setCustomerId(patch.customerId === null ? Maybe.none() : Maybe.some(patch.customerId)));
if (patch.paymentTermId !== undefined) operations.push(recurringProforma.setPaymentTermId(patch.paymentTermId === null ? Maybe.none() : Maybe.some(patch.paymentTermId)));
if (patch.paymentMethodId !== undefined) operations.push(recurringProforma.setPaymentMethodId(patch.paymentMethodId === null ? Maybe.none() : Maybe.some(patch.paymentMethodId)));
if (patch.language !== undefined) operations.push(recurringProforma.setLanguage(patch.language === null ? Maybe.none() : Maybe.some(patch.language)));
if (patch.currency !== undefined) operations.push(recurringProforma.setCurrency(patch.currency === null ? Maybe.none() : Maybe.some(patch.currency)));
if (patch.proformaSeries !== undefined) operations.push(recurringProforma.setProformaSeries(patch.proformaSeries === null ? Maybe.none() : Maybe.some(patch.proformaSeries)));
if (patch.issuedInvoiceSeries !== undefined) operations.push(recurringProforma.setIssuedInvoiceSeries(patch.issuedInvoiceSeries === null ? Maybe.none() : Maybe.some(patch.issuedInvoiceSeries)));
if (schedule !== undefined) operations.push(recurringProforma.setSchedule(schedule, { hasOccurrences }));
if (patch.items !== undefined) operations.push(recurringProforma.replaceItems(patch.items));
let changed = false;
for (const operation of operations) {
if (operation.isFailure) return Result.fail(operation.error);
changed ||= operation.data;
}
return Result.ok(changed);
}
}

View File

@ -3,3 +3,4 @@ export * from "./company-verifactu-profile";
export * from "./invoice-series";
export * from "./issued-invoices";
export * from "./proformas";
export * from "./recurring-proformas";

View File

@ -0,0 +1,5 @@
export * from "./recurrence-schedule";
export * from "./recurring-proforma-item";
export * from "./recurring-proforma-items";
export * from "./recurring-proforma";
export * from "./recurring-proforma-occurrence";

View File

@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import { RecurrenceSchedule } from "./recurrence-schedule";
const required = <T>(result: { data: T }): T => result.data;
const isoDate = (value: Date | null): string | null => value?.toISOString().slice(0, 10) ?? null;
test("RecurrenceSchedule valida monthlyRule para recurrencias mensuales", () => {
const result = RecurrenceSchedule.create({
startDate: new Date("2026-01-15T00:00:00.000Z"),
unit: "month",
interval: 1,
});
assert.equal(result.isFailure, true);
});
test("RecurrenceSchedule rechaza monthlyRule fuera de recurrencias mensuales", () => {
const result = RecurrenceSchedule.create({
startDate: new Date("2027-01-15T23:00:00.000-02:00"),
unit: "week",
interval: 1,
monthlyRule: "same_day",
});
assert.equal(result.isFailure, true);
});
test("RecurrenceSchedule calcula mensual same_day anclado al inicio", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-31T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(0))), "2026-01-31");
assert.equal(isoDate(required(schedule.occurrenceAt(1))), "2026-02-28");
assert.equal(isoDate(required(schedule.occurrenceAt(2))), "2026-03-31");
assert.equal(isoDate(required(schedule.occurrenceAt(3))), "2026-04-30");
});
test("RecurrenceSchedule calcula monthly last_day_of_month desde un ancla no final", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-15T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "last_day_of_month",
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(0))), "2026-01-15");
assert.equal(isoDate(required(schedule.occurrenceAt(1))), "2026-02-28");
assert.equal(isoDate(required(schedule.occurrenceAt(2))), "2026-03-31");
});
test("RecurrenceSchedule calcula monthly interval 3 sin perder el ancla", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-15T00:00:00.000Z"),
unit: "month",
interval: 3,
monthlyRule: "last_day_of_month",
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(1))), "2026-04-30");
assert.equal(isoDate(required(schedule.occurrenceAt(2))), "2026-07-31");
assert.equal(isoDate(required(schedule.occurrenceAt(3))), "2026-10-31");
});
test("RecurrenceSchedule calcula yearly bisiesto anclado al 29 de febrero", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2028-02-29T00:00:00.000Z"),
unit: "year",
interval: 1,
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(1))), "2029-02-28");
assert.equal(isoDate(required(schedule.occurrenceAt(2))), "2030-02-28");
assert.equal(isoDate(required(schedule.occurrenceAt(3))), "2031-02-28");
assert.equal(isoDate(required(schedule.occurrenceAt(4))), "2032-02-29");
});
test("RecurrenceSchedule respeta endDate inclusiva", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-15T00:00:00.000Z"),
endDate: new Date("2026-02-12T00:00:00.000Z"),
unit: "week",
interval: 2,
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(0))), "2026-01-15");
assert.equal(isoDate(required(schedule.occurrenceAt(1))), "2026-01-29");
assert.equal(isoDate(required(schedule.occurrenceAt(2))), "2026-02-12");
assert.equal(required(schedule.occurrenceAt(3)), null);
});
test("RecurrenceSchedule no convierte endDate en una occurrence adicional", () => {
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2027-01-15T00:00:00.000Z"),
endDate: new Date("2027-01-20T00:00:00.000Z"),
unit: "week",
interval: 1,
})
);
assert.equal(isoDate(required(schedule.occurrenceAt(0))), "2027-01-15");
assert.equal(required(schedule.occurrenceAt(1)), null);
});

View File

@ -0,0 +1,198 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
export type RecurrenceUnit = "week" | "month" | "year";
export type MonthlyRule = "same_day" | "last_day_of_month";
export interface RecurrenceScheduleProps {
startDate: Date;
endDate: Date | null;
unit: RecurrenceUnit;
interval: number;
monthlyRule: MonthlyRule | null;
}
export class RecurrenceSchedule extends ValueObject<RecurrenceScheduleProps> {
private static readonly FIELD = "recurrenceSchedule";
public static create(props: {
startDate: Date;
endDate?: Date | null;
unit: RecurrenceUnit;
interval: number;
monthlyRule?: MonthlyRule | null;
}): Result<RecurrenceSchedule, Error> {
const startDate = RecurrenceSchedule.normalizeDate(props.startDate);
const endDate = props.endDate ? RecurrenceSchedule.normalizeDate(props.endDate) : null;
const monthlyRule = props.monthlyRule ?? null;
if (!Number.isInteger(props.interval) || props.interval <= 0) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRENCE_INTERVAL",
`${RecurrenceSchedule.FIELD}.interval`,
"Recurrence interval must be a positive integer."
)
);
}
if (props.unit === "month" && monthlyRule === null) {
return Result.fail(
new DomainValidationError(
"INVALID_MONTHLY_RULE",
`${RecurrenceSchedule.FIELD}.monthlyRule`,
"Monthly recurrences require a monthlyRule."
)
);
}
if (props.unit !== "month" && monthlyRule !== null) {
return Result.fail(
new DomainValidationError(
"INVALID_MONTHLY_RULE",
`${RecurrenceSchedule.FIELD}.monthlyRule`,
"Only monthly recurrences can define monthlyRule."
)
);
}
if (endDate !== null && endDate.getTime() < startDate.getTime()) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRENCE_END_DATE",
`${RecurrenceSchedule.FIELD}.endDate`,
"End date cannot be earlier than start date."
)
);
}
return Result.ok(
new RecurrenceSchedule({
startDate,
endDate,
unit: props.unit,
interval: props.interval,
monthlyRule,
})
);
}
public get startDate(): Date {
return RecurrenceSchedule.cloneDate(this.props.startDate);
}
public get endDate(): Date | null {
return this.props.endDate ? RecurrenceSchedule.cloneDate(this.props.endDate) : null;
}
public get unit(): RecurrenceUnit {
return this.props.unit;
}
public get interval(): number {
return this.props.interval;
}
public get monthlyRule(): MonthlyRule | null {
return this.props.monthlyRule;
}
public occurrenceAt(index: number): Result<Date | null, Error> {
if (!Number.isInteger(index) || index < 0) {
return Result.fail(
new DomainValidationError(
"INVALID_OCCURRENCE_INDEX",
`${RecurrenceSchedule.FIELD}.occurrenceIndex`,
"Occurrence index must be a non-negative integer."
)
);
}
const occurrenceDate = this.calculateOccurrenceAt(index);
if (
this.props.endDate !== null &&
occurrenceDate.getTime() > this.props.endDate.getTime()
) {
return Result.ok(null);
}
return Result.ok(occurrenceDate);
}
public getProps(): RecurrenceScheduleProps {
return {
startDate: this.startDate,
endDate: this.endDate,
unit: this.unit,
interval: this.interval,
monthlyRule: this.monthlyRule,
};
}
private calculateOccurrenceAt(index: number): Date {
switch (this.props.unit) {
case "week":
return this.addWeeks(index);
case "month":
return this.addMonths(index);
case "year":
return this.addYears(index);
}
}
private addWeeks(index: number): Date {
const anchor = this.props.startDate;
const nextDate = RecurrenceSchedule.cloneDate(anchor);
nextDate.setUTCDate(anchor.getUTCDate() + index * this.props.interval * 7);
return nextDate;
}
private addMonths(index: number): Date {
if (index === 0) {
return RecurrenceSchedule.cloneDate(this.props.startDate);
}
const anchor = this.props.startDate;
const totalMonths = anchor.getUTCMonth() + index * this.props.interval;
const targetYear = anchor.getUTCFullYear() + Math.floor(totalMonths / 12);
const targetMonth = ((totalMonths % 12) + 12) % 12;
const lastDayOfTargetMonth = RecurrenceSchedule.lastDayOfMonth(targetYear, targetMonth);
const targetDay =
this.props.monthlyRule === "last_day_of_month"
? lastDayOfTargetMonth
: Math.min(anchor.getUTCDate(), lastDayOfTargetMonth);
return new Date(Date.UTC(targetYear, targetMonth, targetDay));
}
private addYears(index: number): Date {
if (index === 0) {
return RecurrenceSchedule.cloneDate(this.props.startDate);
}
const anchor = this.props.startDate;
const targetYear = anchor.getUTCFullYear() + index * this.props.interval;
const targetMonth = anchor.getUTCMonth();
const lastDayOfTargetMonth = RecurrenceSchedule.lastDayOfMonth(targetYear, targetMonth);
const targetDay = Math.min(anchor.getUTCDate(), lastDayOfTargetMonth);
return new Date(Date.UTC(targetYear, targetMonth, targetDay));
}
private static lastDayOfMonth(year: number, month: number): number {
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
private static normalizeDate(value: Date): Date {
return new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())
);
}
private static cloneDate(value: Date): Date {
return new Date(value.getTime());
}
}

View File

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { ItemAmount, ItemDescription, ItemQuantity } from "../common";
import { RecurringProformaItem } from "./recurring-proforma-item";
const required = <T>(result: { data: T }): T => result.data;
test("RecurringProformaItem reutiliza los VOs reales de proforma", () => {
const description = required(ItemDescription.create("Servicio recurrente"));
const quantity = required(ItemQuantity.create({ value: 2 }));
const currencyCode = required(CurrencyCode.create("EUR"));
const unitAmount = required(
ItemAmount.create({ value: 15.25, currency_code: currencyCode.code })
);
const discount = required(DiscountPercentage.create({ value: 10 }));
const item = required(
RecurringProformaItem.create({
description: Maybe.some(description),
quantity: Maybe.some(quantity),
unitAmount: Maybe.some(unitAmount),
itemDiscountPercentage: Maybe.some(discount),
})
);
assert.equal(item.description.unwrap(), description);
assert.equal(item.quantity.unwrap(), quantity);
assert.equal(item.unitAmount.unwrap(), unitAmount);
assert.equal(item.itemDiscountPercentage.unwrap(), discount);
});
test("RecurringProformaItem conserva códigos fiscales configurables sin resolver impuestos", () => {
const empty = required(RecurringProformaItem.create({
description: Maybe.none(), quantity: Maybe.none(), unitAmount: Maybe.none(), itemDiscountPercentage: Maybe.none(),
}));
const configured = required(RecurringProformaItem.create({
description: Maybe.none(), quantity: Maybe.none(), unitAmount: Maybe.none(), itemDiscountPercentage: Maybe.none(),
ivaCode: Maybe.some("iva_21"), recCode: Maybe.some("rec_5_2"), retentionCode: Maybe.some("retention_15"),
}));
assert.equal(empty.ivaCode.isNone(), true);
assert.equal(empty.recCode.isNone(), true);
assert.equal(empty.retentionCode.isNone(), true);
assert.equal(configured.ivaCode.unwrap(), "iva_21");
assert.equal(configured.recCode.unwrap(), "rec_5_2");
assert.equal(configured.retentionCode.unwrap(), "retention_15");
});

View File

@ -0,0 +1,77 @@
import { type DiscountPercentage } from "@erp/core/api";
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import type { ItemAmount, ItemDescription, ItemQuantity } from "../common";
export interface RecurringProformaItemCreateProps {
description: Maybe<ItemDescription>;
quantity: Maybe<ItemQuantity>;
unitAmount: Maybe<ItemAmount>;
itemDiscountPercentage: Maybe<DiscountPercentage>;
ivaCode?: Maybe<string>;
recCode?: Maybe<string>;
retentionCode?: Maybe<string>;
}
type RecurringProformaItemInternalProps = Omit<RecurringProformaItemCreateProps, "ivaCode" | "recCode" | "retentionCode"> & {
ivaCode: Maybe<string>;
recCode: Maybe<string>;
retentionCode: Maybe<string>;
};
export class RecurringProformaItem extends DomainEntity<RecurringProformaItemInternalProps> {
public static create(
props: RecurringProformaItemCreateProps,
id?: UniqueID
): Result<RecurringProformaItem, Error> {
return Result.ok(new RecurringProformaItem({
...props,
ivaCode: props.ivaCode ?? Maybe.none(),
recCode: props.recCode ?? Maybe.none(),
retentionCode: props.retentionCode ?? Maybe.none(),
}, id));
}
public static rehydrate(
props: RecurringProformaItemInternalProps,
id: UniqueID
): RecurringProformaItem {
return new RecurringProformaItem(props, id);
}
protected constructor(props: RecurringProformaItemInternalProps, id?: UniqueID) {
super(props, id);
}
public get description(): Maybe<ItemDescription> {
return this.props.description;
}
public get quantity(): Maybe<ItemQuantity> {
return this.props.quantity;
}
public get unitAmount(): Maybe<ItemAmount> {
return this.props.unitAmount;
}
public get itemDiscountPercentage(): Maybe<DiscountPercentage> {
return this.props.itemDiscountPercentage;
}
public get ivaCode(): Maybe<string> { return this.props.ivaCode; }
public get recCode(): Maybe<string> { return this.props.recCode; }
public get retentionCode(): Maybe<string> { return this.props.retentionCode; }
public getProps(): RecurringProformaItemCreateProps {
return {
...this.props,
ivaCode: this.ivaCode,
recCode: this.recCode,
retentionCode: this.retentionCode,
};
}
}

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Maybe } from "@repo/rdx-utils";
import { ItemDescription } from "../common";
import { RecurringProformaItem } from "./recurring-proforma-item";
import { RecurringProformaItems } from "./recurring-proforma-items";
const required = <T>(result: { data: T }): T => result.data;
test("RecurringProformaItems mantiene el orden de inserción", () => {
const first = required(
RecurringProformaItem.create({
description: Maybe.some(required(ItemDescription.create("Primero"))),
quantity: Maybe.none(),
unitAmount: Maybe.none(),
itemDiscountPercentage: Maybe.none(),
})
);
const second = required(
RecurringProformaItem.create({
description: Maybe.some(required(ItemDescription.create("Segundo"))),
quantity: Maybe.none(),
unitAmount: Maybe.none(),
itemDiscountPercentage: Maybe.none(),
})
);
const items = RecurringProformaItems.create({ items: [first] });
items.add(second);
assert.deepEqual(
items.getAll().map((item) => item.description.unwrap().toPrimitive()),
["Primero", "Segundo"]
);
});

View File

@ -0,0 +1,17 @@
import { Collection } from "@repo/rdx-utils";
import type { RecurringProformaItem } from "./recurring-proforma-item";
export interface RecurringProformaItemsProps {
items?: RecurringProformaItem[];
}
export class RecurringProformaItems extends Collection<RecurringProformaItem> {
private constructor(props: RecurringProformaItemsProps) {
super(props.items ?? []);
}
public static create(props: RecurringProformaItemsProps): RecurringProformaItems {
return new RecurringProformaItems(props);
}
}

View File

@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { RecurringProformaOccurrence } from "./recurring-proforma-occurrence";
const required = <T>(result: { data: T }): T => result.data;
const createOccurrence = () =>
required(
RecurringProformaOccurrence.create(
{
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000201")),
recurringProformaId: required(
UniqueID.create("01900000-0000-7000-8000-000000000202")
),
scheduledDate: new Date("2026-09-01T00:00:00.000Z"),
createdAt: new Date("2026-08-30T09:00:00.000Z"),
updatedAt: new Date("2026-08-30T09:00:00.000Z"),
},
required(UniqueID.create("01900000-0000-7000-8000-000000000203"))
)
);
test("RecurringProformaOccurrence marca processing incrementando intento y timestamps", () => {
const occurrence = createOccurrence();
const now = new Date("2026-08-30T10:00:00.000Z");
const result = occurrence.markProcessing(now);
assert.equal(result.isSuccess, true);
assert.equal(occurrence.status, "processing");
assert.equal(occurrence.attemptCount, 1);
assert.equal(occurrence.lastAttemptAt.unwrap().toISOString(), now.toISOString());
assert.equal(occurrence.processingStartedAt.unwrap().toISOString(), now.toISOString());
});
test("RecurringProformaOccurrence marca failed guardando el último error", () => {
const occurrence = createOccurrence();
required(occurrence.markProcessing(new Date("2026-08-30T10:00:00.000Z")));
const result = occurrence.markFailed({
now: new Date("2026-08-30T10:05:00.000Z"),
errorCode: "SERIES_NOT_FOUND",
errorMessage: "Series not found",
});
assert.equal(result.isSuccess, true);
assert.equal(occurrence.status, "failed");
assert.equal(occurrence.processingStartedAt.isNone(), true);
assert.equal(occurrence.lastErrorCode.unwrap(), "SERIES_NOT_FOUND");
assert.equal(occurrence.lastErrorMessage.unwrap(), "Series not found");
});
test("RecurringProformaOccurrence permite reintento desde failed hacia processing", () => {
const occurrence = createOccurrence();
required(occurrence.markProcessing(new Date("2026-08-30T10:00:00.000Z")));
required(
occurrence.markFailed({
now: new Date("2026-08-30T10:05:00.000Z"),
errorCode: "SERIES_NOT_FOUND",
errorMessage: "Series not found",
})
);
required(occurrence.markProcessing(new Date("2026-08-30T10:10:00.000Z")));
assert.equal(occurrence.status, "processing");
assert.equal(occurrence.attemptCount, 2);
});
test("RecurringProformaOccurrence marca generated limpiando errores y fijando proformaId", () => {
const occurrence = createOccurrence();
const generatedAt = new Date("2026-09-01T08:00:00.000Z");
required(occurrence.markProcessing(new Date("2026-08-30T10:00:00.000Z")));
required(
occurrence.markFailed({
now: new Date("2026-08-30T10:05:00.000Z"),
errorCode: "TEMP_ERROR",
errorMessage: "Temporary error",
})
);
required(occurrence.markProcessing(new Date("2026-08-30T10:10:00.000Z")));
const result = occurrence.markGenerated({
now: new Date("2026-09-01T08:01:00.000Z"),
proformaId: required(UniqueID.create("01900000-0000-7000-8000-000000000204")),
generatedAt,
});
assert.equal(result.isSuccess, true);
assert.equal(occurrence.status, "generated");
assert.equal(occurrence.processingStartedAt.isNone(), true);
assert.equal(occurrence.lastErrorCode.isNone(), true);
assert.equal(occurrence.lastErrorMessage.isNone(), true);
assert.equal(
occurrence.proformaId.unwrap().toPrimitive(),
"01900000-0000-7000-8000-000000000204"
);
assert.equal(occurrence.generatedAt.unwrap().toISOString(), generatedAt.toISOString());
});
test("RecurringProformaOccurrence no permite generated sin pasar por processing ni sin datos obligatorios", () => {
const occurrence = createOccurrence();
assert.equal(
occurrence.markGenerated({
now: new Date("2026-09-01T08:01:00.000Z"),
proformaId: required(UniqueID.create("01900000-0000-7000-8000-000000000204")),
generatedAt: new Date("2026-09-01T08:00:00.000Z"),
}).isFailure,
true
);
const invalidGenerated = RecurringProformaOccurrence.create({
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000201")),
recurringProformaId: required(UniqueID.create("01900000-0000-7000-8000-000000000202")),
scheduledDate: new Date("2026-09-01T00:00:00.000Z"),
status: "generated",
proformaId: Maybe.none(),
generatedAt: Maybe.none(),
createdAt: new Date("2026-08-30T09:00:00.000Z"),
updatedAt: new Date("2026-08-30T09:00:00.000Z"),
});
assert.equal(invalidGenerated.isFailure, true);
});
test("RecurringProformaOccurrence bloquea transiciones desde generated y rehidratación inconsistente", () => {
const occurrence = createOccurrence();
required(occurrence.markProcessing(new Date("2026-08-30T10:00:00.000Z")));
required(
occurrence.markGenerated({
now: new Date("2026-09-01T08:01:00.000Z"),
proformaId: required(UniqueID.create("01900000-0000-7000-8000-000000000204")),
generatedAt: new Date("2026-09-01T08:00:00.000Z"),
})
);
assert.equal(occurrence.markProcessing(new Date("2026-09-01T09:00:00.000Z")).isFailure, true);
const invalidRehydrate = RecurringProformaOccurrence.rehydrate(
{
companyId: occurrence.companyId,
recurringProformaId: occurrence.recurringProformaId,
scheduledDate: occurrence.scheduledDate,
status: "processing",
attemptCount: occurrence.attemptCount,
lastAttemptAt: occurrence.lastAttemptAt,
processingStartedAt: Maybe.none(),
lastErrorCode: occurrence.lastErrorCode,
lastErrorMessage: occurrence.lastErrorMessage,
proformaId: Maybe.none(),
generatedAt: Maybe.none(),
createdAt: occurrence.createdAt,
updatedAt: occurrence.updatedAt,
},
required(UniqueID.create("01900000-0000-7000-8000-000000000203"))
);
assert.equal(invalidRehydrate.isFailure, true);
const invalidGeneratedRehydrate = RecurringProformaOccurrence.rehydrate(
{
companyId: occurrence.companyId,
recurringProformaId: occurrence.recurringProformaId,
scheduledDate: occurrence.scheduledDate,
status: "generated",
attemptCount: occurrence.attemptCount,
lastAttemptAt: occurrence.lastAttemptAt,
processingStartedAt: Maybe.none(),
lastErrorCode: Maybe.none(),
lastErrorMessage: Maybe.none(),
proformaId: Maybe.none(),
generatedAt: Maybe.none(),
createdAt: occurrence.createdAt,
updatedAt: occurrence.updatedAt,
},
required(UniqueID.create("01900000-0000-7000-8000-000000000203"))
);
assert.equal(invalidGeneratedRehydrate.isFailure, true);
});

View File

@ -0,0 +1,382 @@
import { DomainEntity, DomainValidationError, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export type RecurringProformaOccurrenceStatus =
| "pending"
| "processing"
| "failed"
| "generated";
export interface RecurringProformaOccurrenceCreateProps {
companyId: UniqueID;
recurringProformaId: UniqueID;
scheduledDate: Date;
status?: RecurringProformaOccurrenceStatus;
attemptCount?: number;
lastAttemptAt?: Maybe<Date>;
processingStartedAt?: Maybe<Date>;
lastErrorCode?: Maybe<string>;
lastErrorMessage?: Maybe<string>;
proformaId?: Maybe<UniqueID>;
generatedAt?: Maybe<Date>;
createdAt: Date;
updatedAt: Date;
}
type RecurringProformaOccurrenceInternalProps = {
companyId: UniqueID;
recurringProformaId: UniqueID;
scheduledDate: Date;
status: RecurringProformaOccurrenceStatus;
attemptCount: number;
lastAttemptAt: Maybe<Date>;
processingStartedAt: Maybe<Date>;
lastErrorCode: Maybe<string>;
lastErrorMessage: Maybe<string>;
proformaId: Maybe<UniqueID>;
generatedAt: Maybe<Date>;
createdAt: Date;
updatedAt: Date;
};
export class RecurringProformaOccurrence extends DomainEntity<RecurringProformaOccurrenceInternalProps> {
private static readonly FIELD = "recurringProformaOccurrence";
public static create(
props: RecurringProformaOccurrenceCreateProps,
id?: UniqueID
): Result<RecurringProformaOccurrence, Error> {
const occurrence = new RecurringProformaOccurrence(
{
companyId: props.companyId,
recurringProformaId: props.recurringProformaId,
scheduledDate: RecurringProformaOccurrence.cloneDate(props.scheduledDate),
status: props.status ?? "pending",
attemptCount: props.attemptCount ?? 0,
lastAttemptAt:
props.lastAttemptAt?.map((value) => RecurringProformaOccurrence.cloneDate(value)) ??
Maybe.none(),
processingStartedAt:
props.processingStartedAt?.map((value) =>
RecurringProformaOccurrence.cloneDate(value)
) ?? Maybe.none(),
lastErrorCode: props.lastErrorCode ?? Maybe.none(),
lastErrorMessage: props.lastErrorMessage ?? Maybe.none(),
proformaId: props.proformaId ?? Maybe.none(),
generatedAt:
props.generatedAt?.map((value) => RecurringProformaOccurrence.cloneDate(value)) ??
Maybe.none(),
createdAt: RecurringProformaOccurrence.cloneDate(props.createdAt),
updatedAt: RecurringProformaOccurrence.cloneDate(props.updatedAt),
},
id
);
const validationResult = occurrence.ensureStateConsistency();
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(occurrence);
}
public static rehydrate(
props: RecurringProformaOccurrenceInternalProps,
id: UniqueID
): Result<RecurringProformaOccurrence, Error> {
const occurrence = new RecurringProformaOccurrence(props, id);
const validationResult = occurrence.ensureStateConsistency();
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(occurrence);
}
protected constructor(props: RecurringProformaOccurrenceInternalProps, id?: UniqueID) {
super(props, id);
}
public markProcessing(now: Date): Result<boolean, Error> {
if (this.status === "processing") {
return Result.ok(false);
}
if (this.status !== "pending" && this.status !== "failed") {
return Result.fail(
this.invalidTransitionError(
this.status,
"processing",
"Only pending or failed occurrences can enter processing."
)
);
}
const currentDate = RecurringProformaOccurrence.cloneDate(now);
this.props.status = "processing";
this.props.attemptCount += 1;
this.props.lastAttemptAt = Maybe.some(currentDate);
this.props.processingStartedAt = Maybe.some(currentDate);
this.props.updatedAt = currentDate;
return Result.ok(true);
}
public markFailed(params: {
now: Date;
errorCode: string;
errorMessage: string;
}): Result<boolean, Error> {
if (this.status === "failed") {
return Result.ok(false);
}
if (this.status !== "processing") {
return Result.fail(
this.invalidTransitionError(
this.status,
"failed",
"Only processing occurrences can be marked as failed."
)
);
}
this.props.status = "failed";
this.props.processingStartedAt = Maybe.none();
this.props.lastErrorCode = Maybe.some(params.errorCode);
this.props.lastErrorMessage = Maybe.some(params.errorMessage);
this.props.updatedAt = RecurringProformaOccurrence.cloneDate(params.now);
return Result.ok(true);
}
public markGenerated(params: {
now: Date;
proformaId: UniqueID;
generatedAt: Date;
}): Result<boolean, Error> {
if (this.status === "generated") {
return Result.ok(false);
}
if (this.status !== "processing") {
return Result.fail(
this.invalidTransitionError(
this.status,
"generated",
"Only processing occurrences can be marked as generated."
)
);
}
if (
!params.proformaId ||
!params.generatedAt ||
Number.isNaN(params.generatedAt.getTime())
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_GENERATED_STATE",
`${RecurringProformaOccurrence.FIELD}.generated`,
"Generated occurrences require proformaId and generatedAt."
)
);
}
this.props.status = "generated";
this.props.proformaId = Maybe.some(params.proformaId);
this.props.generatedAt = Maybe.some(
RecurringProformaOccurrence.cloneDate(params.generatedAt)
);
this.props.lastErrorCode = Maybe.none();
this.props.lastErrorMessage = Maybe.none();
this.props.processingStartedAt = Maybe.none();
this.props.updatedAt = RecurringProformaOccurrence.cloneDate(params.now);
return Result.ok(true);
}
public get companyId(): UniqueID {
return this.props.companyId;
}
public get recurringProformaId(): UniqueID {
return this.props.recurringProformaId;
}
public get scheduledDate(): Date {
return RecurringProformaOccurrence.cloneDate(this.props.scheduledDate);
}
public get status(): RecurringProformaOccurrenceStatus {
return this.props.status;
}
public get attemptCount(): number {
return this.props.attemptCount;
}
public get lastAttemptAt(): Maybe<Date> {
return this.props.lastAttemptAt.map((value) =>
RecurringProformaOccurrence.cloneDate(value)
);
}
public get processingStartedAt(): Maybe<Date> {
return this.props.processingStartedAt.map((value) =>
RecurringProformaOccurrence.cloneDate(value)
);
}
public get lastErrorCode(): Maybe<string> {
return this.props.lastErrorCode;
}
public get lastErrorMessage(): Maybe<string> {
return this.props.lastErrorMessage;
}
public get proformaId(): Maybe<UniqueID> {
return this.props.proformaId;
}
public get generatedAt(): Maybe<Date> {
return this.props.generatedAt.map((value) =>
RecurringProformaOccurrence.cloneDate(value)
);
}
public get createdAt(): Date {
return RecurringProformaOccurrence.cloneDate(this.props.createdAt);
}
public get updatedAt(): Date {
return RecurringProformaOccurrence.cloneDate(this.props.updatedAt);
}
public getProps(): RecurringProformaOccurrenceCreateProps {
return {
companyId: this.companyId,
recurringProformaId: this.recurringProformaId,
scheduledDate: this.scheduledDate,
status: this.status,
attemptCount: this.attemptCount,
lastAttemptAt: this.lastAttemptAt,
processingStartedAt: this.processingStartedAt,
lastErrorCode: this.lastErrorCode,
lastErrorMessage: this.lastErrorMessage,
proformaId: this.proformaId,
generatedAt: this.generatedAt,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
private ensureStateConsistency(): Result<void, Error> {
if (!Number.isInteger(this.props.attemptCount) || this.props.attemptCount < 0) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_ATTEMPT_COUNT",
`${RecurringProformaOccurrence.FIELD}.attemptCount`,
"Attempt count must be a non-negative integer."
)
);
}
if (
this.status === "generated" &&
(this.props.proformaId.isNone() || this.props.generatedAt.isNone())
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_GENERATED_STATE",
`${RecurringProformaOccurrence.FIELD}.generated`,
"Generated occurrences require proformaId and generatedAt."
)
);
}
if (
(this.status === "pending" || this.status === "failed") &&
this.props.processingStartedAt.isSome()
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_PROCESSING_STATE",
`${RecurringProformaOccurrence.FIELD}.processingStartedAt`,
"Only processing occurrences can define processingStartedAt."
)
);
}
if (this.status === "processing" && this.props.processingStartedAt.isNone()) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_PROCESSING_STATE",
`${RecurringProformaOccurrence.FIELD}.processingStartedAt`,
"Processing occurrences require processingStartedAt."
)
);
}
if (
this.status === "failed" &&
(this.props.lastErrorCode.isNone() || this.props.lastErrorMessage.isNone())
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_FAILED_STATE",
`${RecurringProformaOccurrence.FIELD}.lastErrorCode`,
"Failed occurrences require lastErrorCode and lastErrorMessage."
)
);
}
if (
(this.status === "pending" || this.status === "generated") &&
(this.props.lastErrorCode.isSome() || this.props.lastErrorMessage.isSome())
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_ERROR_STATE",
`${RecurringProformaOccurrence.FIELD}.lastErrorCode`,
"Pending and generated occurrences cannot retain errors."
)
);
}
if (
this.status !== "generated" &&
(this.props.proformaId.isSome() || this.props.generatedAt.isSome())
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_GENERATED_STATE",
`${RecurringProformaOccurrence.FIELD}.generated`,
"Only generated occurrences can define proformaId or generatedAt."
)
);
}
return Result.ok();
}
private invalidTransitionError(
currentStatus: RecurringProformaOccurrenceStatus,
nextStatus: RecurringProformaOccurrenceStatus,
detail: string
): DomainValidationError {
return new DomainValidationError(
"INVALID_RECURRING_PROFORMA_OCCURRENCE_TRANSITION",
`${RecurringProformaOccurrence.FIELD}.status`,
`Invalid transition for recurring proforma occurrence ${this.id.toString()}: ${currentStatus} -> ${nextStatus}. ${detail}`
);
}
private static cloneDate(value: Date): Date {
return new Date(value.getTime());
}
}

View File

@ -0,0 +1,397 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscountPercentage } from "@erp/core/api";
import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { ItemAmount, ItemDescription, ItemQuantity } from "../common";
import { InvoiceSerie } from "../common";
import { RecurrenceSchedule } from "./recurrence-schedule";
import type { RecurringProformaItemCreateProps } from "./recurring-proforma-item";
import {
RecurringProforma,
type RecurringProformaInternalProps,
} from "./recurring-proforma";
import { RecurringProformaItems } from "./recurring-proforma-items";
const required = <T>(result: { data: T }): T => result.data;
const createItem = (description: string): RecurringProformaItemCreateProps => {
const currencyCode = required(CurrencyCode.create("EUR"));
return {
description: Maybe.some(required(ItemDescription.create(description))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 1 }))),
unitAmount: Maybe.some(
required(ItemAmount.create({ value: 10, currency_code: currencyCode.code }))
),
itemDiscountPercentage: Maybe.some(required(DiscountPercentage.create({ value: 5 }))),
};
};
const createDraft = () =>
required(
RecurringProforma.create(
{
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000101")),
name: "Cuota mensual",
},
required(UniqueID.create("01900000-0000-7000-8000-000000000102"))
)
);
test("RecurringProforma permite crear draft solo con name", () => {
const recurringProforma = createDraft();
assert.equal(recurringProforma.status, "draft");
assert.equal(recurringProforma.name, "Cuota mensual");
assert.equal(recurringProforma.customerId.isNone(), true);
assert.equal(recurringProforma.schedule.isNone(), true);
assert.equal(recurringProforma.items.size(), 0);
assert.equal(recurringProforma.nextOccurrenceDate.isNone(), true);
assert.equal(recurringProforma.description.isNone(), true);
});
test("RecurringProforma conserva y limpia preferencias de series funcionales", () => {
const recurringProforma = createDraft();
const proformaSeries = required(InvoiceSerie.create("PRO"));
const issuedInvoiceSeries = required(InvoiceSerie.create("INV"));
assert.equal(required(recurringProforma.setProformaSeries(Maybe.some(proformaSeries))), true);
assert.equal(required(recurringProforma.setIssuedInvoiceSeries(Maybe.some(issuedInvoiceSeries))), true);
assert.equal(recurringProforma.proformaSeries.unwrap().toPrimitive(), "PRO");
assert.equal(recurringProforma.issuedInvoiceSeries.unwrap().toPrimitive(), "INV");
assert.equal(required(recurringProforma.setProformaSeries(Maybe.none())), true);
assert.equal(required(recurringProforma.setIssuedInvoiceSeries(Maybe.none())), true);
assert.equal(recurringProforma.proformaSeries.isNone(), true);
assert.equal(recurringProforma.issuedInvoiceSeries.isNone(), true);
});
test("RecurringProforma inicializa y expone timestamps inyectados", () => {
const createdAt = new Date("2026-01-01T10:00:00.000Z");
const updatedAt = new Date("2026-01-02T10:00:00.000Z");
const recurringProforma = required(
RecurringProforma.create({
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000101")),
name: "Cuota",
createdAt,
updatedAt,
})
);
assert.equal(recurringProforma.createdAt.toISOString(), createdAt.toISOString());
assert.equal(recurringProforma.updatedAt.toISOString(), updatedAt.toISOString());
});
test("RecurringProforma crea, actualiza y rehidrata la descripción comercial", () => {
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000101"));
const recurringProforma = required(
RecurringProforma.create({
companyId,
name: "Cuota",
description: Maybe.some("Servicio recurrente"),
})
);
assert.equal(recurringProforma.description.unwrap(), "Servicio recurrente");
assert.equal(required(recurringProforma.setDescription(Maybe.some("Servicio actualizado"))), true);
assert.equal(recurringProforma.description.unwrap(), "Servicio actualizado");
const rehydrated = required(
RecurringProforma.rehydrate(
{
companyId,
name: "Persistida",
description: Maybe.some("Descripción persistida"),
globalDiscountPercentage: Maybe.none(),
customerId: Maybe.none(),
schedule: Maybe.none(),
status: "draft",
nextOccurrenceDate: Maybe.none(),
paymentTermId: Maybe.none(),
paymentMethodId: Maybe.none(),
language: Maybe.none(),
currency: Maybe.none(),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
},
RecurringProformaItems.create({ items: [] }),
required(UniqueID.create("01900000-0000-7000-8000-000000000102"))
)
);
assert.equal(rehydrated.description.unwrap(), "Descripción persistida");
});
test("RecurringProforma recorta y rechaza nombres vacíos", () => {
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000101"));
const normalized = required(RecurringProforma.create({ companyId, name: " Cuota " }));
const invalid = RecurringProforma.create({ companyId, name: " " });
assert.equal(normalized.name, "Cuota");
assert.equal(invalid.isFailure, true);
});
test("RecurringProforma conserva overrides como Maybe sin estado de herencia paralelo", () => {
const paymentTermId = required(UniqueID.create("01900000-0000-7000-8000-000000000110"));
const language = required(LanguageCode.create("es"));
const currency = required(CurrencyCode.create("EUR"));
const recurringProforma = required(
RecurringProforma.create({
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000101")),
name: "Cuota",
paymentTermId: Maybe.some(paymentTermId),
paymentMethodId: Maybe.none(),
language: Maybe.some(language),
currency: Maybe.some(currency),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
})
);
assert.equal(recurringProforma.paymentTermId.unwrap(), paymentTermId);
assert.equal(recurringProforma.paymentMethodId.isNone(), true);
assert.equal(recurringProforma.language.unwrap(), language);
assert.equal(recurringProforma.currency.unwrap(), currency);
assert.equal(recurringProforma.proformaSeries.isNone(), true);
assert.equal(recurringProforma.issuedInvoiceSeries.isNone(), true);
});
test("RecurringProforma permite configurar y limpiar customer, overrides y descuento global", () => {
const recurringProforma = createDraft();
const id = required(UniqueID.create("01900000-0000-7000-8000-000000000120"));
const language = required(LanguageCode.create("es"));
const currency = required(CurrencyCode.create("EUR"));
const discount = required(DiscountPercentage.create({ value: 15, scale: 2 }));
required(recurringProforma.setCustomerId(Maybe.some(id)));
required(recurringProforma.setCustomerId(Maybe.none()));
required(recurringProforma.setPaymentTermId(Maybe.some(id)));
required(recurringProforma.setPaymentMethodId(Maybe.some(id)));
required(recurringProforma.setLanguage(Maybe.some(language)));
required(recurringProforma.setCurrency(Maybe.some(currency)));
required(recurringProforma.setProformaSeriesId(Maybe.some(id)));
required(recurringProforma.setIssuedInvoiceSeriesId(Maybe.some(id)));
required(recurringProforma.setGlobalDiscountPercentage(Maybe.some(discount)));
assert.equal(recurringProforma.globalDiscountPercentage.unwrap(), discount);
required(recurringProforma.setPaymentTermId(Maybe.none()));
required(recurringProforma.setPaymentMethodId(Maybe.none()));
required(recurringProforma.setLanguage(Maybe.none()));
required(recurringProforma.setCurrency(Maybe.none()));
required(recurringProforma.setProformaSeriesId(Maybe.none()));
required(recurringProforma.setIssuedInvoiceSeriesId(Maybe.none()));
required(recurringProforma.setGlobalDiscountPercentage(Maybe.none()));
assert.equal(recurringProforma.globalDiscountPercentage.isNone(), true);
});
test("RecurringProforma exige customerId, schedule e items para activar", () => {
const recurringProforma = createDraft();
const activationResult = recurringProforma.activate();
assert.equal(activationResult.isFailure, true);
});
test("RecurringProforma activa desde draft usando startDate como nextOccurrenceDate", () => {
const recurringProforma = createDraft();
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-01-15T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
required(
recurringProforma.setCustomerId(
required(UniqueID.create("01900000-0000-7000-8000-000000000103"))
)
);
required(recurringProforma.setSchedule(schedule));
required(recurringProforma.replaceItems([createItem("Servicio base")]));
const activationResult = recurringProforma.activate();
assert.equal(activationResult.isSuccess, true);
assert.equal(recurringProforma.status, "active");
assert.equal(
recurringProforma.nextOccurrenceDate.unwrap().toISOString().slice(0, 10),
"2026-01-15"
);
});
test("RecurringProforma pausa y reanuda conservando nextOccurrenceDate", () => {
const recurringProforma = createDraft();
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
unit: "week",
interval: 1,
})
);
required(
recurringProforma.setCustomerId(
required(UniqueID.create("01900000-0000-7000-8000-000000000103"))
)
);
required(recurringProforma.setSchedule(schedule));
required(recurringProforma.replaceItems([createItem("Servicio base")]));
required(recurringProforma.activate());
const beforePause = recurringProforma.nextOccurrenceDate.unwrap().toISOString();
required(recurringProforma.pause());
required(recurringProforma.activate());
assert.equal(recurringProforma.status, "active");
assert.equal(recurringProforma.nextOccurrenceDate.unwrap().toISOString(), beforePause);
});
test("RecurringProforma permite actualizar descripción interna en active y paused", () => {
const recurringProforma = createDraft();
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
unit: "week",
interval: 1,
})
);
required(
recurringProforma.setCustomerId(
required(UniqueID.create("01900000-0000-7000-8000-000000000103"))
)
);
required(recurringProforma.setSchedule(schedule));
required(recurringProforma.replaceItems([createItem("Servicio base")]));
required(recurringProforma.activate());
assert.equal(required(recurringProforma.setDescription(Maybe.some("Activa"))), true);
required(recurringProforma.pause());
assert.equal(required(recurringProforma.setDescription(Maybe.some("Pausada"))), true);
assert.equal(recurringProforma.description.unwrap(), "Pausada");
});
test("RecurringProforma permite avanzar el cursor solo durante active", () => {
const recurringProforma = createDraft();
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
unit: "week",
interval: 1,
})
);
required(
recurringProforma.setCustomerId(
required(UniqueID.create("01900000-0000-7000-8000-000000000103"))
)
);
required(recurringProforma.setSchedule(schedule));
required(recurringProforma.replaceItems([createItem("Servicio base")]));
required(recurringProforma.activate());
assert.equal(
required(recurringProforma.advanceNextOccurrenceDate(new Date("2026-03-08T00:00:00.000Z"))),
true
);
assert.equal(
recurringProforma.nextOccurrenceDate.unwrap().toISOString(),
"2026-03-08T00:00:00.000Z"
);
required(recurringProforma.pause());
assert.equal(
recurringProforma.advanceNextOccurrenceDate(new Date("2026-03-15T00:00:00.000Z")).isFailure,
true
);
});
test("RecurringProforma bloquea cambios de calendario cuando Application informa historial", () => {
const recurringProforma = createDraft();
const originalSchedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
const extendedSchedule = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2027-03-01T00:00:00.000Z"),
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
const changedCalendar = required(
RecurrenceSchedule.create({
startDate: new Date("2026-03-01T00:00:00.000Z"),
unit: "month",
interval: 2,
monthlyRule: "same_day",
})
);
required(recurringProforma.setSchedule(originalSchedule));
assert.equal(required(recurringProforma.setSchedule(extendedSchedule, { hasOccurrences: true })), true);
assert.equal(recurringProforma.setSchedule(changedCalendar, { hasOccurrences: true }).isFailure, true);
});
test("RecurringProforma limpia nextOccurrenceDate al completar y bloquea mutaciones terminales", () => {
const recurringProforma = createDraft();
required(recurringProforma.complete());
assert.equal(recurringProforma.status, "completed");
assert.equal(recurringProforma.nextOccurrenceDate.isNone(), true);
assert.equal(recurringProforma.rename("Otro nombre").isFailure, true);
assert.equal(recurringProforma.setDescription(Maybe.some("No permitido")).isFailure, true);
assert.equal(
recurringProforma.setGlobalDiscountPercentage(
Maybe.some(required(DiscountPercentage.create({ value: 5 })))
).isFailure,
true
);
assert.equal(required(recurringProforma.archive()), true);
assert.equal(recurringProforma.status, "archived");
assert.equal(recurringProforma.replaceItems([createItem("No permitido")]).isFailure, true);
assert.equal(recurringProforma.setDescription(Maybe.some("Tampoco permitido")).isFailure, true);
assert.equal(recurringProforma.setPaymentTermId(Maybe.some(required(UniqueID.create("01900000-0000-7000-8000-000000000120")))).isFailure, true);
});
test("RecurringProforma rechaza rehidratación de estados estructuralmente imposibles", () => {
const props: RecurringProformaInternalProps = {
companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000101")),
name: "Persistida",
description: Maybe.none(),
globalDiscountPercentage: Maybe.none(),
customerId: Maybe.none(),
schedule: Maybe.none(),
status: "active",
nextOccurrenceDate: Maybe.none(),
paymentTermId: Maybe.none(),
paymentMethodId: Maybe.none(),
language: Maybe.none(),
currency: Maybe.none(),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
};
const result = RecurringProforma.rehydrate(
props,
RecurringProformaItems.create({ items: [] }),
required(UniqueID.create("01900000-0000-7000-8000-000000000102"))
);
assert.equal(result.isFailure, true);
});

View File

@ -0,0 +1,766 @@
import type { DiscountPercentage } from "@erp/core/api";
import {
AggregateRoot,
type CurrencyCode,
DomainValidationError,
type LanguageCode,
type UniqueID,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { InvoiceSerie } from "../common";
import type { RecurrenceSchedule } from "./recurrence-schedule";
import type { RecurringProformaItemCreateProps } from "./recurring-proforma-item";
import { RecurringProformaItem } from "./recurring-proforma-item";
import { RecurringProformaItems } from "./recurring-proforma-items";
export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived";
export interface RecurringProformaCreateProps {
companyId: UniqueID;
name: string;
description?: Maybe<string>;
globalDiscountPercentage?: Maybe<DiscountPercentage>;
customerId?: Maybe<UniqueID>;
schedule?: Maybe<RecurrenceSchedule>;
nextOccurrenceDate?: Maybe<Date>;
paymentTermId?: Maybe<UniqueID>;
paymentMethodId?: Maybe<UniqueID>;
language?: Maybe<LanguageCode>;
currency?: Maybe<CurrencyCode>;
proformaSeries?: Maybe<InvoiceSerie>;
issuedInvoiceSeries?: Maybe<InvoiceSerie>;
createdAt?: Date;
updatedAt?: Date;
items?: RecurringProformaItemCreateProps[];
status?: RecurringProformaStatus;
}
export interface RecurringProformaInternalProps {
companyId: UniqueID;
name: string;
description: Maybe<string>;
globalDiscountPercentage: Maybe<DiscountPercentage>;
customerId: Maybe<UniqueID>;
schedule: Maybe<RecurrenceSchedule>;
status: RecurringProformaStatus;
nextOccurrenceDate: Maybe<Date>;
paymentTermId: Maybe<UniqueID>;
paymentMethodId: Maybe<UniqueID>;
language: Maybe<LanguageCode>;
currency: Maybe<CurrencyCode>;
proformaSeries: Maybe<InvoiceSerie>;
issuedInvoiceSeries: Maybe<InvoiceSerie>;
createdAt: Date;
updatedAt: Date;
}
export class RecurringProforma extends AggregateRoot<RecurringProformaInternalProps> {
private static readonly FIELD = "recurringProforma";
private readonly itemsCollection: RecurringProformaItems;
protected constructor(
props: RecurringProformaInternalProps,
items: RecurringProformaItems,
id?: UniqueID
) {
super(props, id);
this.itemsCollection = items;
}
public static create(
props: RecurringProformaCreateProps,
id?: UniqueID
): Result<RecurringProforma, Error> {
const name = props.name.trim();
if (name.length === 0) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NAME",
`${RecurringProforma.FIELD}.name`,
"Recurring proforma name is required."
)
);
}
const itemsResult = RecurringProforma.buildItems(props.items ?? []);
if (itemsResult.isFailure) {
return Result.fail(itemsResult.error);
}
const now = props.createdAt ?? new Date();
const recurringProforma = new RecurringProforma(
{
companyId: props.companyId,
name,
description: props.description ?? Maybe.none(),
globalDiscountPercentage: props.globalDiscountPercentage ?? Maybe.none(),
customerId: props.customerId ?? Maybe.none(),
schedule: props.schedule ?? Maybe.none(),
status: props.status ?? "draft",
nextOccurrenceDate:
props.nextOccurrenceDate?.map((value) => RecurringProforma.cloneDate(value)) ??
Maybe.none(),
paymentTermId: props.paymentTermId ?? Maybe.none(),
paymentMethodId: props.paymentMethodId ?? Maybe.none(),
language: props.language ?? Maybe.none(),
currency: props.currency ?? Maybe.none(),
proformaSeries: props.proformaSeries ?? Maybe.none(),
issuedInvoiceSeries: props.issuedInvoiceSeries ?? Maybe.none(),
createdAt: new Date(now.getTime()),
updatedAt: new Date((props.updatedAt ?? now).getTime()),
},
itemsResult.data,
id
);
const validationResult = recurringProforma.ensureStateConsistency();
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(recurringProforma);
}
public static rehydrate(
props: RecurringProformaInternalProps,
items: RecurringProformaItems,
id: UniqueID
): Result<RecurringProforma, Error> {
const recurringProforma = new RecurringProforma(props, items, id);
const validationResult = recurringProforma.ensureStateConsistency();
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(recurringProforma);
}
public rename(name: string): Result<boolean, Error> {
const mutableResult = this.ensureMutable();
if (mutableResult.isFailure) {
return Result.fail(mutableResult.error);
}
const normalizedName = name.trim();
if (normalizedName.length === 0) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NAME",
`${RecurringProforma.FIELD}.name`,
"Recurring proforma name is required."
)
);
}
if (this.props.name === normalizedName) {
return Result.ok(false);
}
this.props.name = normalizedName;
return Result.ok(true);
}
public setCustomerId(customerId: Maybe<UniqueID> | UniqueID): Result<boolean, Error> {
const mutableResult = this.ensureMutable();
if (mutableResult.isFailure) {
return Result.fail(mutableResult.error);
}
const nextCustomerId = customerId instanceof Maybe ? customerId : Maybe.some(customerId);
if (
this.sameMaybe(this.props.customerId, nextCustomerId, (left, right) => left.equals(right))
) {
return Result.ok(false);
}
if ((this.status === "active" || this.status === "paused") && nextCustomerId.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_CUSTOMER",
`${RecurringProforma.FIELD}.customerId`,
"Active or paused recurring proformas require customerId."
)
);
}
this.props.customerId = nextCustomerId;
return Result.ok(true);
}
public setDescription(description: Maybe<string>): Result<boolean, Error> {
const mutableResult = this.ensureMutable();
if (mutableResult.isFailure) {
return Result.fail(mutableResult.error);
}
if (this.sameMaybe(this.props.description, description, (left, right) => left === right)) {
return Result.ok(false);
}
this.props.description = description;
return Result.ok(true);
}
public setGlobalDiscountPercentage(value: Maybe<DiscountPercentage>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.globalDiscountPercentage,
value,
(next) => {
this.props.globalDiscountPercentage = next;
},
(left, right) => left.equals(right)
);
}
public setPaymentTermId(value: Maybe<UniqueID>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.paymentTermId,
value,
(next) => {
this.props.paymentTermId = next;
},
(left, right) => left.equals(right)
);
}
public setPaymentMethodId(value: Maybe<UniqueID>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.paymentMethodId,
value,
(next) => {
this.props.paymentMethodId = next;
},
(left, right) => left.equals(right)
);
}
public setLanguage(value: Maybe<LanguageCode>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.language,
value,
(next) => {
this.props.language = next;
},
(left, right) => left.equals(right)
);
}
public setCurrency(value: Maybe<CurrencyCode>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.currency,
value,
(next) => {
this.props.currency = next;
},
(left, right) => left.equals(right)
);
}
public setProformaSeries(value: Maybe<InvoiceSerie>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.proformaSeries,
value,
(next) => {
this.props.proformaSeries = next;
},
(left, right) => left.equals(right)
);
}
public setIssuedInvoiceSeries(value: Maybe<InvoiceSerie>): Result<boolean, Error> {
return this.setMaybeOverride(
this.props.issuedInvoiceSeries,
value,
(next) => {
this.props.issuedInvoiceSeries = next;
},
(left, right) => left.equals(right)
);
}
public setSchedule(
schedule: RecurrenceSchedule,
options?: { hasOccurrences?: boolean }
): Result<boolean, Error> {
const mutableResult = this.ensureMutable();
if (mutableResult.isFailure) {
return Result.fail(mutableResult.error);
}
if (this.props.schedule.isSome() && this.props.schedule.unwrap().equals(schedule)) {
return Result.ok(false);
}
if (
options?.hasOccurrences === true &&
this.props.schedule.isSome() &&
!this.hasSameCalendar(this.props.schedule.unwrap(), schedule)
) {
return Result.fail(
new DomainValidationError(
"RECURRING_PROFORMA_CALENDAR_LOCKED",
`${RecurringProforma.FIELD}.schedule`,
"Start date, unit, interval and monthlyRule cannot change after occurrences exist."
)
);
}
this.props.schedule = Maybe.some(schedule);
if (this.status === "draft") {
this.props.nextOccurrenceDate = Maybe.none();
}
return Result.ok(true);
}
public replaceItems(items: RecurringProformaItemCreateProps[]): Result<boolean, Error> {
const mutableResult = this.ensureMutable();
if (mutableResult.isFailure) {
return Result.fail(mutableResult.error);
}
const nextItemsResult = RecurringProforma.buildItems(items);
if (nextItemsResult.isFailure) {
return Result.fail(nextItemsResult.error);
}
if (this.haveSameItems(nextItemsResult.data)) {
return Result.ok(false);
}
this.itemsCollection.reset();
for (const item of nextItemsResult.data.getAll()) {
this.itemsCollection.add(item);
}
return Result.ok(true);
}
public activate(): Result<boolean, Error> {
if (this.status === "active") {
return Result.ok(false);
}
if (this.status !== "draft" && this.status !== "paused") {
return Result.fail(
this.invalidTransitionError(this.status, "active", "Only draft or paused can be activated.")
);
}
if (this.props.customerId.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_CUSTOMER",
`${RecurringProforma.FIELD}.customerId`,
"Recurring proforma requires customerId before activation."
)
);
}
if (this.props.schedule.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_SCHEDULE",
`${RecurringProforma.FIELD}.schedule`,
"Recurring proforma requires schedule before activation."
)
);
}
if (this.itemsCollection.size() === 0) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_ITEMS",
`${RecurringProforma.FIELD}.items`,
"Recurring proforma requires at least one item before activation."
)
);
}
if (this.status === "draft") {
const firstOccurrenceResult = this.props.schedule.unwrap().occurrenceAt(0);
if (firstOccurrenceResult.isFailure) {
return Result.fail(firstOccurrenceResult.error);
}
const firstOccurrence = firstOccurrenceResult.data;
if (firstOccurrence === null) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_SCHEDULE",
`${RecurringProforma.FIELD}.schedule`,
"Recurring proforma schedule does not produce a first occurrence."
)
);
}
this.props.nextOccurrenceDate = Maybe.some(firstOccurrence);
}
this.props.status = "active";
return Result.ok(true);
}
public pause(): Result<boolean, Error> {
if (this.status === "paused") {
return Result.ok(false);
}
if (this.status !== "active") {
return Result.fail(
this.invalidTransitionError(this.status, "paused", "Only active can be paused.")
);
}
this.props.status = "paused";
return Result.ok(true);
}
public complete(): Result<boolean, Error> {
if (this.status === "completed") {
return Result.ok(false);
}
if (this.status !== "draft" && this.status !== "active" && this.status !== "paused") {
return Result.fail(
this.invalidTransitionError(
this.status,
"completed",
"Only draft, active or paused can be completed."
)
);
}
this.props.status = "completed";
this.props.nextOccurrenceDate = Maybe.none();
return Result.ok(true);
}
public archive(): Result<boolean, Error> {
if (this.status === "archived") {
return Result.ok(false);
}
if (this.status !== "completed") {
return Result.fail(
this.invalidTransitionError(this.status, "archived", "Only completed can be archived.")
);
}
this.props.status = "archived";
this.props.nextOccurrenceDate = Maybe.none();
return Result.ok(true);
}
/**
* P4 debe proporcionar el siguiente cursor calculado desde el calendario anclado.
*/
public advanceNextOccurrenceDate(nextOccurrenceDate: Date): Result<boolean, Error> {
if (this.status !== "active") {
return Result.fail(
this.invalidTransitionError(
this.status,
"active",
"Only active recurring proformas can advance nextOccurrenceDate."
)
);
}
const currentNextOccurrenceDate = this.props.nextOccurrenceDate.unwrap();
const nextDate = RecurringProforma.cloneDate(nextOccurrenceDate);
if (nextDate.getTime() <= currentNextOccurrenceDate.getTime()) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NEXT_OCCURRENCE",
`${RecurringProforma.FIELD}.nextOccurrenceDate`,
"Next occurrence date must advance the current cursor."
)
);
}
this.props.nextOccurrenceDate = Maybe.some(nextDate);
return Result.ok(true);
}
public get companyId(): UniqueID {
return this.props.companyId;
}
public get name(): string {
return this.props.name;
}
public get description(): Maybe<string> {
return this.props.description;
}
public get globalDiscountPercentage(): Maybe<DiscountPercentage> {
return this.props.globalDiscountPercentage;
}
public get customerId(): Maybe<UniqueID> {
return this.props.customerId;
}
public get schedule(): Maybe<RecurrenceSchedule> {
return this.props.schedule;
}
public get status(): RecurringProformaStatus {
return this.props.status;
}
public get nextOccurrenceDate(): Maybe<Date> {
return this.props.nextOccurrenceDate.map((value) => RecurringProforma.cloneDate(value));
}
public get paymentTermId(): Maybe<UniqueID> {
return this.props.paymentTermId;
}
public get paymentMethodId(): Maybe<UniqueID> {
return this.props.paymentMethodId;
}
public get language(): Maybe<LanguageCode> {
return this.props.language;
}
public get currency(): Maybe<CurrencyCode> {
return this.props.currency;
}
public get proformaSeries(): Maybe<InvoiceSerie> {
return this.props.proformaSeries;
}
public get issuedInvoiceSeries(): Maybe<InvoiceSerie> {
return this.props.issuedInvoiceSeries;
}
public get createdAt(): Date {
return RecurringProforma.cloneDate(this.props.createdAt);
}
public get updatedAt(): Date {
return RecurringProforma.cloneDate(this.props.updatedAt);
}
public get items(): RecurringProformaItems {
return this.itemsCollection;
}
private ensureStateConsistency(): Result<void, Error> {
if (this.status === "draft" && this.props.nextOccurrenceDate.isSome()) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NEXT_OCCURRENCE",
`${RecurringProforma.FIELD}.nextOccurrenceDate`,
"Draft recurring proformas cannot define nextOccurrenceDate."
)
);
}
if (
(this.status === "completed" || this.status === "archived") &&
this.props.nextOccurrenceDate.isSome()
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NEXT_OCCURRENCE",
`${RecurringProforma.FIELD}.nextOccurrenceDate`,
"Completed or archived recurring proformas cannot define nextOccurrenceDate."
)
);
}
if ((this.status === "active" || this.status === "paused") && this.props.schedule.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_SCHEDULE",
`${RecurringProforma.FIELD}.schedule`,
"Active or paused recurring proformas require a schedule."
)
);
}
if ((this.status === "active" || this.status === "paused") && this.props.customerId.isNone()) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_CUSTOMER",
`${RecurringProforma.FIELD}.customerId`,
"Active or paused recurring proformas require customerId."
)
);
}
if (
(this.status === "active" || this.status === "paused") &&
this.itemsCollection.size() === 0
) {
return Result.fail(
new DomainValidationError(
"MISSING_RECURRING_PROFORMA_ITEMS",
`${RecurringProforma.FIELD}.items`,
"Active or paused recurring proformas require at least one item."
)
);
}
if (
(this.status === "active" || this.status === "paused") &&
this.props.nextOccurrenceDate.isNone()
) {
return Result.fail(
new DomainValidationError(
"INVALID_RECURRING_PROFORMA_NEXT_OCCURRENCE",
`${RecurringProforma.FIELD}.nextOccurrenceDate`,
"Active or paused recurring proformas require nextOccurrenceDate."
)
);
}
return Result.ok();
}
private ensureMutable(): Result<void, Error> {
if (this.status === "completed" || this.status === "archived") {
return Result.fail(
new DomainValidationError(
"IMMUTABLE_RECURRING_PROFORMA",
`${RecurringProforma.FIELD}.status`,
"Completed and archived recurring proformas are immutable."
)
);
}
return Result.ok();
}
private haveSameItems(items: RecurringProformaItems): boolean {
const currentItems = this.itemsCollection.getAll();
const nextItems = items.getAll();
if (currentItems.length !== nextItems.length) {
return false;
}
return currentItems.every((item, index) => this.sameItem(item, nextItems[index]));
}
private setMaybeOverride<T>(
current: Maybe<T>,
value: Maybe<T>,
assign: (value: Maybe<T>) => void,
equals: (left: T, right: T) => boolean
): Result<boolean, Error> {
const mutable = this.ensureMutable();
if (mutable.isFailure) return Result.fail(mutable.error);
if (this.sameMaybe(current, value, equals)) return Result.ok(false);
assign(value);
return Result.ok(true);
}
private hasSameCalendar(left: RecurrenceSchedule, right: RecurrenceSchedule): boolean {
return (
left.startDate.getTime() === right.startDate.getTime() &&
left.unit === right.unit &&
left.interval === right.interval &&
left.monthlyRule === right.monthlyRule
);
}
private sameItem(left: RecurringProformaItem, right: RecurringProformaItem): boolean {
return (
this.sameMaybe(left.description, right.description, (current, candidate) =>
current.equals(candidate)
) &&
this.sameMaybe(left.quantity, right.quantity, (current, candidate) =>
current.equals(candidate)
) &&
this.sameMaybe(left.unitAmount, right.unitAmount, (current, candidate) =>
current.equals(candidate)
) &&
this.sameMaybe(
left.itemDiscountPercentage,
right.itemDiscountPercentage,
(current, candidate) => current.equals(candidate)
) &&
this.sameMaybe(left.ivaCode, right.ivaCode, (current, candidate) => current === candidate) &&
this.sameMaybe(left.recCode, right.recCode, (current, candidate) => current === candidate) &&
this.sameMaybe(
left.retentionCode,
right.retentionCode,
(current, candidate) => current === candidate
)
);
}
private sameMaybe<T>(
left: Maybe<T>,
right: Maybe<T>,
equals: (current: T, candidate: T) => boolean
): boolean {
if (left.isNone() || right.isNone()) {
return left.isNone() && right.isNone();
}
return equals(left.unwrap(), right.unwrap());
}
private invalidTransitionError(
currentStatus: RecurringProformaStatus,
nextStatus: RecurringProformaStatus,
detail: string
): DomainValidationError {
return new DomainValidationError(
"INVALID_RECURRING_PROFORMA_TRANSITION",
`${RecurringProforma.FIELD}.status`,
`Invalid transition for recurring proforma ${this.id.toString()}: ${currentStatus} -> ${nextStatus}. ${detail}`
);
}
private static buildItems(
items: RecurringProformaItemCreateProps[]
): Result<RecurringProformaItems, Error> {
const collection = RecurringProformaItems.create({ items: [] });
for (const itemProps of items) {
const itemResult = RecurringProformaItem.create(itemProps);
if (itemResult.isFailure) {
return Result.fail(itemResult.error);
}
collection.add(itemResult.data);
}
return Result.ok(collection);
}
private static cloneDate(value: Date): Date {
return new Date(value.getTime());
}
}

View File

@ -10,6 +10,7 @@ import {
issuedInvoicesRouter,
models,
proformasRouter,
recurringProformasRouter,
} from "./infrastructure";
export type { IProformaPublicServices } from "./application";
@ -73,6 +74,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
companyVerifactuProfileRouter(params);
issuedInvoicesRouter(params);
proformasRouter(params);
recurringProformasRouter(params);
logger.info("🚀 CustomerInvoices module started", {
label: this.name,

View File

@ -6,6 +6,9 @@ import proformaModelInit from "./models/sequelize-proforma.model";
import proformaItemModelInit from "./models/sequelize-proforma-item.model";
import proformaTaxModelInit from "./models/sequelize-proforma-tax.model";
import verifactuRecordModelInit from "./models/verifactu-record.model";
import recurringProformaModelInit from "./models/recurring-proforma.model";
import recurringProformaItemModelInit from "./models/recurring-proforma-item.model";
import recurringProformaOccurrenceModelInit from "./models/recurring-proforma-occurrence.model";
export * from "./models";
@ -21,4 +24,7 @@ export const models = [
issuedInvoiceTaxModelInit,
verifactuRecordModelInit,
recurringProformaModelInit,
recurringProformaItemModelInit,
recurringProformaOccurrenceModelInit,
];

View File

@ -6,3 +6,6 @@ export * from "./sequelize-issued-invoice.model";
export * from "./sequelize-issued-invoice-item.model";
export * from "./sequelize-issued-invoice-tax.model";
export * from "./verifactu-record.model";
export * from "./recurring-proforma.model";
export * from "./recurring-proforma-item.model";
export * from "./recurring-proforma-occurrence.model";

View File

@ -0,0 +1,67 @@
import {
DataTypes,
type InferAttributes,
type InferCreationAttributes,
Model,
type NonAttribute,
type Sequelize,
} from "sequelize";
import type { RecurringProformaModel } from "./recurring-proforma.model";
export class RecurringProformaItemModel extends Model<
InferAttributes<RecurringProformaItemModel>,
InferCreationAttributes<RecurringProformaItemModel, { omit: "recurring_proforma" }>
> {
declare id: string;
declare recurring_proforma_id: string;
declare description: string | null;
declare quantity_value: number | null;
declare quantity_scale: number;
declare unit_amount_value: number | null;
declare unit_amount_scale: number;
declare unit_amount_currency: string | null;
declare item_discount_percentage_value: number | null;
declare item_discount_percentage_scale: number;
declare iva_code: string | null;
declare rec_code: string | null;
declare retention_code: string | null;
declare position: number;
declare recurring_proforma: NonAttribute<RecurringProformaModel>;
public static associate(database: Sequelize): void {
const { RecurringProformaModel, RecurringProformaItemModel } = database.models;
if (!RecurringProformaModel || !RecurringProformaItemModel) {
throw new Error("[RecurringProformaItemModel.associate] Missing recurring proforma models");
}
RecurringProformaItemModel.belongsTo(RecurringProformaModel, {
as: "recurring_proforma",
foreignKey: "recurring_proforma_id",
onDelete: "CASCADE",
onUpdate: "CASCADE",
});
}
}
export default (database: Sequelize) => {
RecurringProformaItemModel.init(
{
id: { type: DataTypes.UUID, primaryKey: true } as any,
recurring_proforma_id: { type: DataTypes.UUID, allowNull: false } as any,
description: { type: DataTypes.TEXT, allowNull: true, defaultValue: null },
quantity_value: { type: DataTypes.BIGINT, allowNull: true, defaultValue: null },
quantity_scale: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 2 },
unit_amount_value: { type: DataTypes.BIGINT, allowNull: true, defaultValue: null },
unit_amount_scale: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 4 },
unit_amount_currency: { type: DataTypes.STRING(3), allowNull: true, defaultValue: null },
item_discount_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, defaultValue: null },
item_discount_percentage_scale: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 2 },
iva_code: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null },
rec_code: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null },
retention_code: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null },
position: { type: DataTypes.MEDIUMINT.UNSIGNED, allowNull: false },
},
{ sequelize: database, modelName: "RecurringProformaItemModel", tableName: "recurring_proforma_items", timestamps: false, underscored: true }
);
return RecurringProformaItemModel;
};

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