Proformas recurrentes
This commit is contained in:
parent
288b134ec8
commit
a746bb97ec
@ -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 type { ILogger } from "@repo/rdx-logger";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
@ -13,27 +13,21 @@ let _config: ConfigType | null = null;
|
|||||||
|
|
||||||
export type ConfigType = BaseConfigType & {
|
export type ConfigType = BaseConfigType & {
|
||||||
env: string;
|
env: string;
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: number;
|
port: number;
|
||||||
apiBasePath: string;
|
apiBasePath: string;
|
||||||
frontendUrl: string;
|
frontendUrl: string;
|
||||||
trustProxy: number;
|
trustProxy: number;
|
||||||
timezone: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
database: {
|
database: DatabaseConfig & {
|
||||||
dialect: "mysql" | "postgres" | "sqlite" | "mariadb" | "mssql" | "db2" | "snowflake" | "oracle";
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
name: string;
|
|
||||||
user: string;
|
|
||||||
password: string;
|
|
||||||
logging: boolean;
|
|
||||||
ssl: boolean;
|
|
||||||
syncMode: string;
|
syncMode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
warmup: { timeoutMs: number; strict: boolean };
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: string;
|
secret: string;
|
||||||
@ -89,13 +83,13 @@ export const initConfig = (logger: ILogger): ConfigType => {
|
|||||||
|
|
||||||
_config = {
|
_config = {
|
||||||
env: env.NODE_ENV,
|
env: env.NODE_ENV,
|
||||||
|
timezone: env.TIMEZONE,
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
port: env.PORT,
|
port: env.PORT,
|
||||||
apiBasePath: env.API_BASE_PATH,
|
apiBasePath: env.API_BASE_PATH,
|
||||||
frontendUrl: env.FRONTEND_URL,
|
frontendUrl: env.FRONTEND_URL,
|
||||||
trustProxy: env.TRUST_PROXY,
|
trustProxy: env.TRUST_PROXY,
|
||||||
timezone: env.TIMEZONE,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
database: {
|
database: {
|
||||||
@ -107,9 +101,12 @@ export const initConfig = (logger: ILogger): ConfigType => {
|
|||||||
password: env.DB_PASS,
|
password: env.DB_PASS,
|
||||||
logging: env.DB_LOGGING,
|
logging: env.DB_LOGGING,
|
||||||
ssl: env.DB_SSL,
|
ssl: env.DB_SSL,
|
||||||
|
timezone: env.TIMEZONE,
|
||||||
syncMode: env.DB_SYNC_MODE,
|
syncMode: env.DB_SYNC_MODE,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
warmup: { timeoutMs: env.WARMUP_TIMEOUT_MS, strict: env.WARMUP_STRICT },
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: env.JWT_SECRET,
|
secret: env.JWT_SECRET,
|
||||||
|
|||||||
@ -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";
|
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.
|
* autentica la conexión y devuelve la instancia lista para usar.
|
||||||
*/
|
*/
|
||||||
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
|
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
|
||||||
const sequelize = buildSequelize(config);
|
return await createSequelize(config.database, logger);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cierra la instancia de Sequelize (para integrar con el shutdown).
|
* Cierra la instancia de Sequelize (para integrar con el shutdown).
|
||||||
*/
|
*/
|
||||||
export async function closeDatabase(sequelize: Sequelize): Promise<void> {
|
export async function closeDatabase(sequelize: Sequelize): Promise<void> {
|
||||||
try {
|
await closeSequelize(sequelize, logger);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
const NodeEnvSchema = z.enum(["development", "test", "production"]);
|
const NodeEnvSchema = z.enum(["development", "test", "production"]);
|
||||||
|
|
||||||
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
|
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({
|
export const CompanyCertificateSchema = z.object({
|
||||||
certificateId: z.string().min(1),
|
certificateId: z.string().min(1),
|
||||||
|
|||||||
@ -3,32 +3,30 @@ import os from "node:os";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AsyncLocalStorageOperationContextStore,
|
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,
|
AuditChangeModel,
|
||||||
AuditEventModel,
|
AuditEventModel,
|
||||||
|
OperationAuditContextProvider,
|
||||||
SequelizeAuditStore,
|
SequelizeAuditStore,
|
||||||
|
SequelizeTransactionContext,
|
||||||
auditModelInitializers,
|
auditModelInitializers,
|
||||||
} from "./infrastructure/audit/sequelize";
|
|
||||||
import { listRoutes, logger } from "./lib/index.ts";
|
|
||||||
import {
|
|
||||||
getServiceScoped,
|
getServiceScoped,
|
||||||
initModules,
|
initModules,
|
||||||
listServices,
|
listServices,
|
||||||
registerModels,
|
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";
|
import { registerModules } from "./register-modules.ts";
|
||||||
|
|
||||||
z.config(z.locales.es());
|
z.config(z.locales.es());
|
||||||
@ -56,6 +54,7 @@ let isShuttingDown = false;
|
|||||||
let shutdownPromise: Promise<void> | null = null;
|
let shutdownPromise: Promise<void> | null = null;
|
||||||
// 🔒 Bandera de readiness (se actualiza durante arranque y apagado)
|
// 🔒 Bandera de readiness (se actualiza durante arranque y apagado)
|
||||||
let isReady = false;
|
let isReady = false;
|
||||||
|
let database: Sequelize | null = null;
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Manejo de cierre forzado del servidor (graceful shutdown + sockets)
|
// 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...`);
|
logger.warn(`Received ${signal}. Starting graceful shutdown...`);
|
||||||
try {
|
try {
|
||||||
await serverStop(server);
|
await serverStop(server);
|
||||||
|
if (database) await closeDatabase(database);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Error during shutdown, forcing exit(1)", { err, label: "handleSignal" });
|
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;
|
isReady = false;
|
||||||
try {
|
try {
|
||||||
await serverStop(server);
|
await serverStop(server);
|
||||||
|
if (database) await closeDatabase(database);
|
||||||
} finally {
|
} finally {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@ -218,6 +219,7 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
isReady = false;
|
isReady = false;
|
||||||
try {
|
try {
|
||||||
await serverStop(server);
|
await serverStop(server);
|
||||||
|
if (database) await closeDatabase(database);
|
||||||
} finally {
|
} finally {
|
||||||
process.exit(1);
|
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(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
|
||||||
logger.info(`Process PID: ${process.pid}`);
|
logger.info(`Process PID: ${process.pid}`);
|
||||||
|
|
||||||
const database = await tryConnectToDatabase(config);
|
database = await tryConnectToDatabase(config);
|
||||||
|
|
||||||
// Lógica de inicialización de DB, si procede:
|
// Lógica de inicialización de DB, si procede:
|
||||||
// initStructure(sequelizeConn.connection);
|
// initStructure(sequelizeConn.connection);
|
||||||
@ -241,7 +243,7 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
|
|
||||||
// ➕ Rutas de salud disponibles desde el inicio del proceso
|
// ➕ Rutas de salud disponibles desde el inicio del proceso
|
||||||
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
|
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
|
||||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
registerModels(auditModelInitializers, "server-infrastructure");
|
||||||
const auditStore = new SequelizeAuditStore(
|
const auditStore = new SequelizeAuditStore(
|
||||||
AuditEventModel,
|
AuditEventModel,
|
||||||
AuditChangeModel,
|
AuditChangeModel,
|
||||||
@ -254,6 +256,20 @@ process.on("uncaughtException", async (error: Error) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await initModules({
|
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,
|
config,
|
||||||
app,
|
app,
|
||||||
database,
|
database,
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
|
import { AuditChangeModel, AuditEventModel } from "@erp/core/api";
|
||||||
import type { AuditValue } from "@repo/rdx-audit";
|
import type { AuditValue } from "@repo/rdx-audit";
|
||||||
import type { Criteria, FiltersPrimitives } from "@repo/rdx-criteria/server";
|
import type { Criteria, FiltersPrimitives } from "@repo/rdx-criteria/server";
|
||||||
import { Op, Sequelize } from "sequelize";
|
import { Op, Sequelize } from "sequelize";
|
||||||
|
|
||||||
import { AuditChangeModel, AuditEventModel } from "./sequelize";
|
|
||||||
|
|
||||||
export type AuditReadAccess =
|
export type AuditReadAccess =
|
||||||
| { readonly mode: "unrestricted" }
|
| { readonly mode: "unrestricted" }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
export * from "./audit-change.model";
|
// Compatibilidad temporal para rutas HTTP y tests del servidor.
|
||||||
export * from "./audit-event.model";
|
// La persistencia reutilizable reside en @erp/core/api.
|
||||||
export * from "./sequelize-audit-entry.mapper";
|
export {
|
||||||
export * from "./sequelize-audit-store";
|
AuditChangeModel,
|
||||||
|
AuditEventModel,
|
||||||
import { initializeAuditChangeModel } from "./audit-change.model";
|
SequelizeAuditStore,
|
||||||
import { initializeAuditEventModel } from "./audit-event.model";
|
auditModelInitializers,
|
||||||
|
} from "@erp/core/api";
|
||||||
/** Initializers globales, registrados una vez por el runtime antes de initModels(). */
|
|
||||||
export const auditModelInitializers = [initializeAuditEventModel, initializeAuditChangeModel];
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
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 { AuditStoreError } from "@repo/rdx-audit";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
import type { ModelStatic } from "sequelize";
|
import type { ModelStatic } from "sequelize";
|
||||||
@ -19,8 +19,8 @@ export class SequelizeAuditStore implements IAuditStore {
|
|||||||
private readonly transactionContext: ISequelizeTransactionContext
|
private readonly transactionContext: ISequelizeTransactionContext
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async append(entry: AuditEntry): Promise<Result<void, AuditStoreError>> {
|
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
|
||||||
const transaction = this.transactionContext.current();
|
const transaction = options?.transaction ?? this.transactionContext.current();
|
||||||
if (entry.kind === "mutation" && !transaction) {
|
if (entry.kind === "mutation" && !transaction) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { configureLogger, setLoggerSingleton } from "@repo/rdx-logger";
|
import { configureLogger, setLoggerSingleton } from "@repo/rdx-logger";
|
||||||
|
|
||||||
const logger = configureLogger("console"); // o según config/env
|
const logger = configureLogger("console", { service: "server" });
|
||||||
setLoggerSingleton(logger);
|
setLoggerSingleton(logger);
|
||||||
|
|
||||||
export { logger };
|
export { logger };
|
||||||
|
|||||||
@ -1,216 +1,2 @@
|
|||||||
// apps/server/src/lib/modules/model-loader.ts
|
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
|
||||||
import type { ModuleParams } from "@erp/core/api";
|
export { registerModels, resetModuleRuntimeForTests as resetModelsRegistry } 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" });
|
|
||||||
};
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { Audit } from "@repo/rdx-audit";
|
|||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { resetModelsRegistry } from "./model-loader";
|
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";
|
import { listServices, resetServiceRegistry } from "./service-registry";
|
||||||
|
|
||||||
type FakeDatabase = {
|
type FakeDatabase = {
|
||||||
@ -33,6 +33,8 @@ function buildModuleParams(): ModuleParams & {
|
|||||||
syncMode: "none",
|
syncMode: "none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
warmup: { timeoutMs: 10_000, strict: false },
|
||||||
|
databaseSyncMode: "none",
|
||||||
database: {
|
database: {
|
||||||
sync: async () => undefined,
|
sync: async () => undefined,
|
||||||
models: {},
|
models: {},
|
||||||
@ -86,6 +88,7 @@ test("module-loader permite dependencias externas declaradas", async (t) => {
|
|||||||
registerModule(companiesModule);
|
registerModule(companiesModule);
|
||||||
|
|
||||||
await initModules(buildModuleParams());
|
await initModules(buildModuleParams());
|
||||||
|
await startModules(buildModuleParams());
|
||||||
|
|
||||||
assert.equal(receivedService, "catalogs-service");
|
assert.equal(receivedService, "catalogs-service");
|
||||||
assert.deepEqual(listServices().sort(), ["catalogs:general"]);
|
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(
|
await assert.rejects(
|
||||||
() => initModules(buildModuleParams()),
|
async () => {
|
||||||
|
await initModules(buildModuleParams());
|
||||||
|
await startModules(buildModuleParams());
|
||||||
|
},
|
||||||
/without declaring dependency on "catalogs"/i
|
/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(
|
await assert.rejects(
|
||||||
() => initModules(buildModuleParams()),
|
async () => {
|
||||||
|
await initModules(buildModuleParams());
|
||||||
|
await startModules(buildModuleParams());
|
||||||
|
},
|
||||||
/without declaring dependency on "companies"/i
|
/without declaring dependency on "companies"/i
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,374 +1,7 @@
|
|||||||
import type {
|
/** Compatibilidad para imports internos históricos; el runtime vive en @erp/core/api. */
|
||||||
IModuleServer,
|
export {
|
||||||
ModuleParams,
|
initModules,
|
||||||
ModuleSetupResult,
|
registerModule,
|
||||||
SetupParams,
|
resetModuleRuntimeForTests as resetModuleRegistry,
|
||||||
StartParams,
|
startModules,
|
||||||
WarmupParams,
|
|
||||||
} from "@erp/core/api";
|
} 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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,81 +1,6 @@
|
|||||||
const services: Record<string, unknown> = {};
|
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
|
||||||
|
export {
|
||||||
/**
|
getServiceScoped,
|
||||||
* Registra un objeto de servicio (API) bajo un nombre.
|
listServices,
|
||||||
*/
|
resetModuleRuntimeForTests as resetServiceRegistry,
|
||||||
export function registerService(name: string, api: unknown) {
|
} from "@erp/core/api";
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import catalogsAPIModule from "@erp/catalogs/api";
|
import catalogsAPIModule from "@erp/catalogs/api";
|
||||||
import companiesAPIModule from "@erp/companies/api";
|
import companiesAPIModule from "@erp/companies/api";
|
||||||
|
import { registerModule } from "@erp/core/api";
|
||||||
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
||||||
import customersAPIModule from "@erp/customers/api";
|
import customersAPIModule from "@erp/customers/api";
|
||||||
import documentSeriesAPIModule from "@erp/document-series/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 identityAPIModule from "@erp/identity/api";
|
||||||
import suppliersAPIModule from "@erp/suppliers/api";
|
import suppliersAPIModule from "@erp/suppliers/api";
|
||||||
|
|
||||||
import { registerModule } from "./lib";
|
|
||||||
|
|
||||||
// Aquí hay que registrar los módulos que
|
// Aquí hay que registrar los módulos que
|
||||||
// queramos que se carguen en el servidor.
|
// queramos que se carguen en el servidor.
|
||||||
// El registro hace que estén disponibles
|
// El registro hace que estén disponibles
|
||||||
|
|||||||
@ -10,6 +10,13 @@ import {
|
|||||||
type ISequelizeTransactionContext,
|
type ISequelizeTransactionContext,
|
||||||
OperationAuditContextProvider,
|
OperationAuditContextProvider,
|
||||||
SequelizeTransactionContext,
|
SequelizeTransactionContext,
|
||||||
|
getServiceScoped,
|
||||||
|
initModules,
|
||||||
|
listServices,
|
||||||
|
registerModels,
|
||||||
|
registerModule,
|
||||||
|
resetModuleRuntimeForTests,
|
||||||
|
startModules,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
import type { IAudit } from "@repo/rdx-audit";
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import { Audit } from "@repo/rdx-audit";
|
import { Audit } from "@repo/rdx-audit";
|
||||||
@ -32,16 +39,6 @@ import {
|
|||||||
auditModelInitializers,
|
auditModelInitializers,
|
||||||
} from "@/infrastructure/audit/sequelize";
|
} from "@/infrastructure/audit/sequelize";
|
||||||
import { logger } from "@/lib/logger.ts";
|
import { logger } from "@/lib/logger.ts";
|
||||||
import {
|
|
||||||
getServiceScoped,
|
|
||||||
initModules,
|
|
||||||
listServices,
|
|
||||||
registerModels,
|
|
||||||
registerModule,
|
|
||||||
resetModelsRegistry,
|
|
||||||
resetModuleRegistry,
|
|
||||||
resetServiceRegistry,
|
|
||||||
} from "@/lib/modules";
|
|
||||||
import { registerModules } from "@/register-modules.ts";
|
import { registerModules } from "@/register-modules.ts";
|
||||||
|
|
||||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
@ -114,8 +111,22 @@ export async function createHttpTestRuntime(
|
|||||||
for (const testModule of options.testModules ?? []) {
|
for (const testModule of options.testModules ?? []) {
|
||||||
registerModule(testModule);
|
registerModule(testModule);
|
||||||
}
|
}
|
||||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
registerModels(auditModelInitializers, "server-infrastructure");
|
||||||
await initModules({
|
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,
|
app,
|
||||||
config,
|
config,
|
||||||
database,
|
database,
|
||||||
@ -207,9 +218,7 @@ function loadTestEnvironment(): void {
|
|||||||
|
|
||||||
function resetServerRuntimeForTests(): void {
|
function resetServerRuntimeForTests(): void {
|
||||||
resetConfigForTests();
|
resetConfigForTests();
|
||||||
resetServiceRegistry();
|
resetModuleRuntimeForTests();
|
||||||
resetModuleRegistry();
|
|
||||||
resetModelsRegistry();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignUniqueTestDatabaseName(): void {
|
function assignUniqueTestDatabaseName(): void {
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ title: "Proformas recurrentes", href: "/recurring-proformas", icon: FileTextIcon },
|
||||||
{ title: "Clientes", href: "/customers", icon: UsersIcon },
|
{ title: "Clientes", href: "/customers", icon: UsersIcon },
|
||||||
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },
|
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },
|
||||||
],
|
],
|
||||||
|
|||||||
@ -88,6 +88,9 @@ export const AppTopbar = () => {
|
|||||||
<DropdownMenuItem onClick={() => navigate("/customers/create")}>
|
<DropdownMenuItem onClick={() => navigate("/customers/create")}>
|
||||||
{t("web.home.actions.newCustomer")}
|
{t("web.home.actions.newCustomer")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/suppliers/create")}>
|
||||||
|
{t("web.home.actions.newSupplier")}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -56,7 +56,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"new": "New",
|
"new": "New",
|
||||||
"newProforma": "New proforma",
|
"newProforma": "New proforma",
|
||||||
"newCustomer": "New customer"
|
"newCustomer": "New customer",
|
||||||
|
"newSupplier": "New supplier"
|
||||||
},
|
},
|
||||||
"kpis": {
|
"kpis": {
|
||||||
"customers": {
|
"customers": {
|
||||||
|
|||||||
@ -56,7 +56,8 @@
|
|||||||
"actions": {
|
"actions": {
|
||||||
"new": "Nuevo",
|
"new": "Nuevo",
|
||||||
"newProforma": "Nueva proforma",
|
"newProforma": "Nueva proforma",
|
||||||
"newCustomer": "Nuevo cliente"
|
"newCustomer": "Nuevo cliente",
|
||||||
|
"newSupplier": "Nuevo proveedor"
|
||||||
},
|
},
|
||||||
"kpis": {
|
"kpis": {
|
||||||
"customers": {
|
"customers": {
|
||||||
|
|||||||
39
apps/worker/package.json
Normal file
39
apps/worker/package.json
Normal 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" }
|
||||||
|
}
|
||||||
18
apps/worker/src/config/worker-config.ts
Normal file
18
apps/worker/src/config/worker-config.ts
Normal 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 } } };
|
||||||
|
}
|
||||||
18
apps/worker/src/config/worker-env-schema.ts
Normal file
18
apps/worker/src/config/worker-env-schema.ts
Normal 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
44
apps/worker/src/index.ts
Normal 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); });
|
||||||
44
apps/worker/src/jobs/recurring-proforma-scheduled-job.ts
Normal file
44
apps/worker/src/jobs/recurring-proforma-scheduled-job.ts
Normal 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() });
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/worker/src/modules/register-modules.ts
Normal file
17
apps/worker/src/modules/register-modules.ts
Normal 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);
|
||||||
|
}
|
||||||
28
apps/worker/src/scheduler/register-jobs.ts
Normal file
28
apps/worker/src/scheduler/register-jobs.ts
Normal 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()));
|
||||||
|
},
|
||||||
|
})];
|
||||||
|
}
|
||||||
1
apps/worker/src/scheduler/scheduled-job.ts
Normal file
1
apps/worker/src/scheduler/scheduled-job.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export interface ScheduledJob { readonly name: string; readonly intervalMs: number; execute(): Promise<void>; }
|
||||||
26
apps/worker/src/scheduler/scheduler.test.ts
Normal file
26
apps/worker/src/scheduler/scheduler.test.ts
Normal 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;
|
||||||
|
});
|
||||||
45
apps/worker/src/scheduler/scheduler.ts
Normal file
45
apps/worker/src/scheduler/scheduler.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/worker/tsconfig.json
Normal file
6
apps/worker/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
3
apps/worker/tsup.config.ts
Normal file
3
apps/worker/tsup.config.ts
Normal 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"] });
|
||||||
@ -33,7 +33,8 @@
|
|||||||
"lineEnding": "lf",
|
"lineEnding": "lf",
|
||||||
"attributePosition": "auto",
|
"attributePosition": "auto",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"bracketSameLine": true
|
"bracketSameLine": true,
|
||||||
|
"expand": "auto"
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@
|
|||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
"./common": "./src/common/index.ts",
|
"./common": "./src/common/index.ts",
|
||||||
"./api": "./src/api/index.ts",
|
"./api": "./src/api/index.ts",
|
||||||
|
"./module": "./src/api/module.ts",
|
||||||
"./client": "./src/web/manifest.ts",
|
"./client": "./src/web/manifest.ts",
|
||||||
"./client/*": "./src/web/*/index.ts"
|
"./client/*": "./src/web/*/index.ts"
|
||||||
},
|
},
|
||||||
@ -53,4 +54,4 @@
|
|||||||
"use-debounce": "^10.1.1",
|
"use-debounce": "^10.1.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,14 @@ export interface FindActiveTaxDefinitionByCodeParams {
|
|||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
code: string;
|
code: string;
|
||||||
atDate: UtcDate;
|
atDate: UtcDate;
|
||||||
|
transaction?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindActiveTaxDefinitionsByCodesParams {
|
export interface FindActiveTaxDefinitionsByCodesParams {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
codes: Collection<string>;
|
codes: Collection<string>;
|
||||||
atDate: UtcDate;
|
atDate: UtcDate;
|
||||||
|
transaction?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaxDefinitionPublicFinder {
|
export interface ITaxDefinitionPublicFinder {
|
||||||
|
|||||||
@ -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 {
|
export * from "./module";
|
||||||
paymentMethodModels,
|
|
||||||
paymentMethodsRouter,
|
|
||||||
paymentTermModels,
|
|
||||||
paymentTermsRouter,
|
|
||||||
taxDefinitionModels,
|
|
||||||
taxDefinitionsRouter,
|
|
||||||
taxRegimeModels,
|
|
||||||
taxRegimesRouter,
|
|
||||||
} from "./infrastructure";
|
|
||||||
import {
|
|
||||||
buildCatalogsDependencies,
|
|
||||||
buildCatalogsPublicServices,
|
|
||||||
} from "./infrastructure/di/catalogs.di";
|
|
||||||
|
|
||||||
export * from "./application/payment-methods/public";
|
/** Integración HTTP sobre el manifest neutral, sin reconstruir dependencias. */
|
||||||
export * from "./application/payment-terms/public";
|
export const catalogsAPIModule = {
|
||||||
export * from "./application/tax-definitions/public";
|
...catalogsModule,
|
||||||
export * from "./application/tax-regimes/public";
|
async start(params: StartParams) {
|
||||||
|
|
||||||
//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
|
|
||||||
paymentMethodsRouter(params);
|
paymentMethodsRouter(params);
|
||||||
paymentTermsRouter(params);
|
paymentTermsRouter(params);
|
||||||
taxRegimesRouter(params);
|
taxRegimesRouter(params);
|
||||||
taxDefinitionsRouter(params);
|
taxDefinitionsRouter(params);
|
||||||
|
params.logger.info("Catalogs module started", { label: catalogsModule.name });
|
||||||
logger.info("🚀 Catalogs module started", {
|
|
||||||
label: this.name,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Warmup opcional (si lo necesitas en el futuro)
|
|
||||||
* ----------------------------------------------
|
|
||||||
* warmup(params) {
|
|
||||||
* ...
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default catalogsAPIModule;
|
export default catalogsAPIModule;
|
||||||
|
|
||||||
|
import type { StartParams } from "@erp/core/api";
|
||||||
|
|||||||
17
modules/catalogs/src/api/module.test.ts
Normal file
17
modules/catalogs/src/api/module.test.ts
Normal 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");
|
||||||
|
});
|
||||||
38
modules/catalogs/src/api/module.ts
Normal file
38
modules/catalogs/src/api/module.ts
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -15,6 +15,7 @@
|
|||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
"./common": "./src/common/index.ts",
|
"./common": "./src/common/index.ts",
|
||||||
"./api": "./src/api/index.ts",
|
"./api": "./src/api/index.ts",
|
||||||
|
"./module": "./src/api/module.ts",
|
||||||
"./client": "./src/web/manifest.ts"
|
"./client": "./src/web/manifest.ts"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@ -50,4 +51,4 @@
|
|||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 } from "./infrastructure/express";
|
||||||
import { companiesRouter, models } from "./infrastructure";
|
import { companiesModule } from "./module";
|
||||||
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
|
|
||||||
|
|
||||||
export type { ICompanyPublicServices } from "./application";
|
export * from "./module";
|
||||||
export * from "./infrastructure/persistence/sequelize";
|
|
||||||
|
|
||||||
export type CompaniesPublicServicesType = ICompanyPublicServices;
|
/** Integración HTTP sobre el manifest neutral de compañías. */
|
||||||
|
export const companiesAPIModule = {
|
||||||
export const companiesAPIModule: IModuleServer = {
|
...companiesModule,
|
||||||
name: "companies",
|
async start(params: StartParams) {
|
||||||
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
|
|
||||||
companiesRouter(params);
|
companiesRouter(params);
|
||||||
|
params.logger.info("Companies module started", { label: companiesModule.name });
|
||||||
logger.info("🚀 Companies module started", {
|
|
||||||
label: this.name,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Warmup opcional (si lo necesitas en el futuro)
|
|
||||||
* ----------------------------------------------
|
|
||||||
* warmup(params) {
|
|
||||||
* ...
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default companiesAPIModule;
|
export default companiesAPIModule;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
|
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 type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -44,7 +44,9 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
|||||||
const { audit, database, getService, transactionContext } = params;
|
const { audit, database, getService, transactionContext } = params;
|
||||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||||
const responseMapper = new CompanyPublicModelMapper();
|
const responseMapper = new CompanyPublicModelMapper();
|
||||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
const companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
|
||||||
|
"identity:general"
|
||||||
|
).companyAccess;
|
||||||
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
||||||
|
|
||||||
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
||||||
@ -84,7 +86,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
|||||||
}),
|
}),
|
||||||
findAvailableCompaniesForAccount: () =>
|
findAvailableCompaniesForAccount: () =>
|
||||||
new FindAvailableCompaniesForAccountUseCase({
|
new FindAvailableCompaniesForAccountUseCase({
|
||||||
identityServices,
|
identityServices: { companyAccess },
|
||||||
repository,
|
repository,
|
||||||
responseMapper,
|
responseMapper,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
|
|||||||
17
modules/companies/src/api/module.test.ts
Normal file
17
modules/companies/src/api/module.test.ts
Normal 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");
|
||||||
|
});
|
||||||
22
modules/companies/src/api/module.ts
Normal file
22
modules/companies/src/api/module.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
1
modules/core/src/api/infrastructure/audit/index.ts
Normal file
1
modules/core/src/api/infrastructure/audit/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./sequelize";
|
||||||
@ -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; }
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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];
|
||||||
@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
export * from "./config";
|
export * from "./config";
|
||||||
|
export * from "./audit";
|
||||||
export * from "./context";
|
export * from "./context";
|
||||||
export * from "./database";
|
export * from "./database";
|
||||||
export * from "./di";
|
export * from "./di";
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
export * from "./mappers";
|
export * from "./mappers";
|
||||||
|
export * from "./database-config";
|
||||||
|
export * from "./sequelize-factory";
|
||||||
export * from "./sequelize-error-translator";
|
export * from "./sequelize-error-translator";
|
||||||
export * from "./sequelize-func";
|
export * from "./sequelize-func";
|
||||||
export * from "./sequelize-repository";
|
export * from "./sequelize-repository";
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +1,4 @@
|
|||||||
export * from "./module-server.interface";
|
export * from "./module-server.interface";
|
||||||
|
export * from "./module-runtime";
|
||||||
|
export * from "./modules-runtime";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|||||||
114
modules/core/src/api/modules/module-runtime.ts
Normal file
114
modules/core/src/api/modules/module-runtime.ts
Normal 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; }
|
||||||
25
modules/core/src/api/modules/modules-runtime.ts
Normal file
25
modules/core/src/api/modules/modules-runtime.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@
|
|||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
"./common": "./src/common/index.ts",
|
"./common": "./src/common/index.ts",
|
||||||
"./api": "./src/api/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",
|
"./api/domain": "./src/api/domain/index.ts",
|
||||||
"./client": "./src/web/manifest.ts",
|
"./client": "./src/web/manifest.ts",
|
||||||
"./globals.css": "./src/web/globals.css"
|
"./globals.css": "./src/web/globals.css"
|
||||||
@ -70,4 +72,4 @@
|
|||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from "./company-verifactu-profile";
|
|||||||
export * from "./invoice-series";
|
export * from "./invoice-series";
|
||||||
export * from "./issued-invoices";
|
export * from "./issued-invoices";
|
||||||
export * from "./proformas";
|
export * from "./proformas";
|
||||||
|
export * from "./recurring-proformas";
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
type ProformaSeriesValidator,
|
type ProformaSeriesValidator,
|
||||||
type ProformaTargetInvoiceSeriesValidator,
|
type ProformaTargetInvoiceSeriesValidator,
|
||||||
type ProformaTaxResolver,
|
type ProformaTaxResolver,
|
||||||
|
ProformaTaxConfigResolver,
|
||||||
} from "../services";
|
} from "../services";
|
||||||
|
|
||||||
export const buildProformaCreator = (deps: {
|
export const buildProformaCreator = (deps: {
|
||||||
@ -23,6 +24,10 @@ export const buildProformaCreator = (deps: {
|
|||||||
companiesServices: ICompanyPublicServices;
|
companiesServices: ICompanyPublicServices;
|
||||||
customerServices: ICustomerPublicServices;
|
customerServices: ICustomerPublicServices;
|
||||||
audit: IAudit;
|
audit: IAudit;
|
||||||
|
taxConfigResolver?: ProformaTaxConfigResolver;
|
||||||
}): IProformaCreator => {
|
}): IProformaCreator => {
|
||||||
return new ProformaCreator(deps);
|
return new ProformaCreator({
|
||||||
|
...deps,
|
||||||
|
taxConfigResolver: deps.taxConfigResolver ?? new ProformaTaxConfigResolver(deps.taxResolver),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./build-proforma-tax-resolver";
|
export * from "./build-proforma-tax-resolver";
|
||||||
export * from "./proforma-payment-resolver";
|
export * from "./proforma-payment-resolver";
|
||||||
export * from "./proforma-tax-resolver";
|
export * from "./proforma-tax-resolver";
|
||||||
|
export * from "./proforma-tax-config-resolver";
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,18 +14,21 @@ import type { ProformaItemTaxCodesInput } from "../../models";
|
|||||||
export interface EnsureProformaTaxRegimeByCodeParams {
|
export interface EnsureProformaTaxRegimeByCodeParams {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
code: Maybe<string>;
|
code: Maybe<string>;
|
||||||
|
transaction?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureProformaItemTaxesParams {
|
export interface EnsureProformaItemTaxesParams {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
atDate: UtcDate;
|
atDate: UtcDate;
|
||||||
taxCodes: ProformaItemTaxCodesInput;
|
taxCodes: ProformaItemTaxCodesInput;
|
||||||
|
transaction?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FindActiveProformaTaxDefinitionByCodeParams {
|
export interface FindActiveProformaTaxDefinitionByCodeParams {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
atDate: UtcDate;
|
atDate: UtcDate;
|
||||||
code: string;
|
code: string;
|
||||||
|
transaction?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,10 +56,9 @@ export class ProformaTaxResolver {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Solo asegurarnos que existe un TaxRegime con ese "code"
|
// Solo asegurarnos que existe un TaxRegime con ese "code"
|
||||||
public async ensureTaxRegimeByCode(params: {
|
public async ensureTaxRegimeByCode(
|
||||||
companyId: UniqueID;
|
params: EnsureProformaTaxRegimeByCodeParams
|
||||||
code: Maybe<string>;
|
): Promise<Result<void, Error>> {
|
||||||
}): Promise<Result<void, Error>> {
|
|
||||||
if (params.code.isNone()) {
|
if (params.code.isNone()) {
|
||||||
return Result.ok(undefined);
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
@ -66,6 +68,7 @@ export class ProformaTaxResolver {
|
|||||||
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
|
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
|
||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
code,
|
code,
|
||||||
|
transaction: params.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
@ -92,6 +95,7 @@ export class ProformaTaxResolver {
|
|||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
codes,
|
codes,
|
||||||
atDate: params.atDate,
|
atDate: params.atDate,
|
||||||
|
transaction: params.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (definitions.isFailure) {
|
if (definitions.isFailure) {
|
||||||
@ -128,6 +132,7 @@ export class ProformaTaxResolver {
|
|||||||
companyId: params.companyId,
|
companyId: params.companyId,
|
||||||
code: params.code,
|
code: params.code,
|
||||||
atDate: params.atDate,
|
atDate: params.atDate,
|
||||||
|
transaction: params.transaction,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,18 +10,18 @@ import {
|
|||||||
InvoiceNumber,
|
InvoiceNumber,
|
||||||
InvoiceSerie,
|
InvoiceSerie,
|
||||||
Proforma,
|
Proforma,
|
||||||
ProformaTaxConfig,
|
|
||||||
} from "../../../domain";
|
} from "../../../domain";
|
||||||
import {
|
import {
|
||||||
PROFORMA_AUDIT_ACTIONS,
|
PROFORMA_AUDIT_ACTIONS,
|
||||||
toProformaAuditState,
|
toProformaAuditState,
|
||||||
toProformaAuditTarget,
|
toProformaAuditTarget,
|
||||||
} from "../audit/proforma-audit";
|
} from "../audit/proforma-audit";
|
||||||
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
|
import type { ProformaCreateInputProps } from "../models";
|
||||||
import type { IProformaRepository } from "../repositories";
|
import type { IProformaRepository } from "../repositories";
|
||||||
|
|
||||||
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
|
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
|
||||||
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-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 { IProformaNumberGenerator } from "./proforma-number-generator.interface";
|
||||||
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
|
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
|
||||||
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
|
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
|
||||||
@ -43,6 +43,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
repository: IProformaRepository;
|
repository: IProformaRepository;
|
||||||
numberService: IProformaNumberGenerator;
|
numberService: IProformaNumberGenerator;
|
||||||
taxResolver: ProformaTaxResolver;
|
taxResolver: ProformaTaxResolver;
|
||||||
|
taxConfigResolver?: ProformaTaxConfigResolver;
|
||||||
paymentResolver: ProformaPaymentResolver;
|
paymentResolver: ProformaPaymentResolver;
|
||||||
proformaSeriesValidator: ProformaSeriesValidator;
|
proformaSeriesValidator: ProformaSeriesValidator;
|
||||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||||
@ -213,6 +214,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
|
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
|
||||||
companyId: _newProps.companyId,
|
companyId: _newProps.companyId,
|
||||||
code: _newProps.taxRegimeCode,
|
code: _newProps.taxRegimeCode,
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (taxRegimeResult.isFailure) {
|
if (taxRegimeResult.isFailure) {
|
||||||
@ -230,11 +232,13 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
return Result.fail(paymentMethodResult.error);
|
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,
|
companyId: _newProps.companyId,
|
||||||
atDate: _newProps.proformaDate,
|
atDate: _newProps.proformaDate,
|
||||||
customer,
|
customer,
|
||||||
override: props.taxConfig,
|
override: props.taxConfig,
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (taxConfigResult.isFailure) {
|
if (taxConfigResult.isFailure) {
|
||||||
@ -251,6 +255,7 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
companyId: _newProps.companyId,
|
companyId: _newProps.companyId,
|
||||||
atDate: _newProps.proformaDate,
|
atDate: _newProps.proformaDate,
|
||||||
taxCodes: item.taxes,
|
taxCodes: item.taxes,
|
||||||
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (taxes.isFailure) {
|
if (taxes.isFailure) {
|
||||||
@ -354,172 +359,4 @@ export class ProformaCreator implements IProformaCreator {
|
|||||||
return Result.ok(resolvedDefaultPaymentTermId);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./recurring-proforma-audit";
|
||||||
@ -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,
|
||||||
|
});
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./repositories";
|
||||||
|
export * from "./audit";
|
||||||
|
export * from "./models";
|
||||||
|
export * from "./services";
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./recurring-proforma-read.models";
|
||||||
@ -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;
|
||||||
|
};
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./recurring-proforma-repository.interface";
|
||||||
|
export * from "./recurring-proforma-occurrence-repository.interface";
|
||||||
@ -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>>;
|
||||||
|
}
|
||||||
@ -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>>;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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";
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
});
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,3 +3,4 @@ export * from "./company-verifactu-profile";
|
|||||||
export * from "./invoice-series";
|
export * from "./invoice-series";
|
||||||
export * from "./issued-invoices";
|
export * from "./issued-invoices";
|
||||||
export * from "./proformas";
|
export * from "./proformas";
|
||||||
|
export * from "./recurring-proformas";
|
||||||
|
|||||||
@ -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";
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
issuedInvoicesRouter,
|
issuedInvoicesRouter,
|
||||||
models,
|
models,
|
||||||
proformasRouter,
|
proformasRouter,
|
||||||
|
recurringProformasRouter,
|
||||||
} from "./infrastructure";
|
} from "./infrastructure";
|
||||||
|
|
||||||
export type { IProformaPublicServices } from "./application";
|
export type { IProformaPublicServices } from "./application";
|
||||||
@ -73,6 +74,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
|
|||||||
companyVerifactuProfileRouter(params);
|
companyVerifactuProfileRouter(params);
|
||||||
issuedInvoicesRouter(params);
|
issuedInvoicesRouter(params);
|
||||||
proformasRouter(params);
|
proformasRouter(params);
|
||||||
|
recurringProformasRouter(params);
|
||||||
|
|
||||||
logger.info("🚀 CustomerInvoices module started", {
|
logger.info("🚀 CustomerInvoices module started", {
|
||||||
label: this.name,
|
label: this.name,
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import proformaModelInit from "./models/sequelize-proforma.model";
|
|||||||
import proformaItemModelInit from "./models/sequelize-proforma-item.model";
|
import proformaItemModelInit from "./models/sequelize-proforma-item.model";
|
||||||
import proformaTaxModelInit from "./models/sequelize-proforma-tax.model";
|
import proformaTaxModelInit from "./models/sequelize-proforma-tax.model";
|
||||||
import verifactuRecordModelInit from "./models/verifactu-record.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";
|
export * from "./models";
|
||||||
|
|
||||||
@ -21,4 +24,7 @@ export const models = [
|
|||||||
issuedInvoiceTaxModelInit,
|
issuedInvoiceTaxModelInit,
|
||||||
|
|
||||||
verifactuRecordModelInit,
|
verifactuRecordModelInit,
|
||||||
|
recurringProformaModelInit,
|
||||||
|
recurringProformaItemModelInit,
|
||||||
|
recurringProformaOccurrenceModelInit,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -6,3 +6,6 @@ export * from "./sequelize-issued-invoice.model";
|
|||||||
export * from "./sequelize-issued-invoice-item.model";
|
export * from "./sequelize-issued-invoice-item.model";
|
||||||
export * from "./sequelize-issued-invoice-tax.model";
|
export * from "./sequelize-issued-invoice-tax.model";
|
||||||
export * from "./verifactu-record.model";
|
export * from "./verifactu-record.model";
|
||||||
|
export * from "./recurring-proforma.model";
|
||||||
|
export * from "./recurring-proforma-item.model";
|
||||||
|
export * from "./recurring-proforma-occurrence.model";
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user