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 dotenv from "dotenv";
|
||||
|
||||
@ -13,27 +13,21 @@ let _config: ConfigType | null = null;
|
||||
|
||||
export type ConfigType = BaseConfigType & {
|
||||
env: string;
|
||||
timezone: string;
|
||||
|
||||
server: {
|
||||
port: number;
|
||||
apiBasePath: string;
|
||||
frontendUrl: string;
|
||||
trustProxy: number;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
database: {
|
||||
dialect: "mysql" | "postgres" | "sqlite" | "mariadb" | "mssql" | "db2" | "snowflake" | "oracle";
|
||||
host: string;
|
||||
port: number;
|
||||
name: string;
|
||||
user: string;
|
||||
password: string;
|
||||
logging: boolean;
|
||||
ssl: boolean;
|
||||
database: DatabaseConfig & {
|
||||
syncMode: string;
|
||||
};
|
||||
|
||||
warmup: { timeoutMs: number; strict: boolean };
|
||||
|
||||
auth: {
|
||||
jwt: {
|
||||
secret: string;
|
||||
@ -89,13 +83,13 @@ export const initConfig = (logger: ILogger): ConfigType => {
|
||||
|
||||
_config = {
|
||||
env: env.NODE_ENV,
|
||||
timezone: env.TIMEZONE,
|
||||
|
||||
server: {
|
||||
port: env.PORT,
|
||||
apiBasePath: env.API_BASE_PATH,
|
||||
frontendUrl: env.FRONTEND_URL,
|
||||
trustProxy: env.TRUST_PROXY,
|
||||
timezone: env.TIMEZONE,
|
||||
},
|
||||
|
||||
database: {
|
||||
@ -107,9 +101,12 @@ export const initConfig = (logger: ILogger): ConfigType => {
|
||||
password: env.DB_PASS,
|
||||
logging: env.DB_LOGGING,
|
||||
ssl: env.DB_SSL,
|
||||
timezone: env.TIMEZONE,
|
||||
syncMode: env.DB_SYNC_MODE,
|
||||
},
|
||||
|
||||
warmup: { timeoutMs: env.WARMUP_TIMEOUT_MS, strict: env.WARMUP_STRICT },
|
||||
|
||||
auth: {
|
||||
jwt: {
|
||||
secret: env.JWT_SECRET,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Sequelize } from "sequelize";
|
||||
import { closeSequelize, createSequelize } from "@erp/core/api";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
@ -9,92 +10,12 @@ import type { ConfigType } from "./index";
|
||||
* autentica la conexión y devuelve la instancia lista para usar.
|
||||
*/
|
||||
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
|
||||
const sequelize = buildSequelize(config);
|
||||
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
logger.info("✔️ Database connection established", {
|
||||
label: "database",
|
||||
// Info no sensible:
|
||||
dialect: sequelize.getDialect(),
|
||||
host: (sequelize.config.host ?? "url") as string,
|
||||
database: (sequelize.config.database ?? "url") as string,
|
||||
});
|
||||
return sequelize;
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ Unable to connect to the database: ${error?.message ?? error}`, {
|
||||
label: "database",
|
||||
});
|
||||
// Cerramos por si quedó algo abierto
|
||||
try {
|
||||
await sequelize.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await createSequelize(config.database, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra la instancia de Sequelize (para integrar con el shutdown).
|
||||
*/
|
||||
export async function closeDatabase(sequelize: Sequelize): Promise<void> {
|
||||
try {
|
||||
await sequelize.close();
|
||||
logger.info("Database connection closed", { label: "database" });
|
||||
} catch (error: any) {
|
||||
logger.error(`Error while closing database: ${error?.message ?? error}`, {
|
||||
label: "database",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildSequelize(config: ConfigType): Sequelize {
|
||||
const common = {
|
||||
logging: config.database.logging
|
||||
? (msg: any) => logger.debug(String(msg), { label: "sequelize" })
|
||||
: false,
|
||||
|
||||
timezone: config.server.timezone,
|
||||
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
idle: 10000,
|
||||
acquire: 30000,
|
||||
},
|
||||
|
||||
dialectOptions: {
|
||||
timezone: config.server.timezone,
|
||||
|
||||
// Para MariaDB/MySQL:
|
||||
charset: "utf8mb4_unicode_ci",
|
||||
},
|
||||
|
||||
define: {
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/*if (ENV.DATABASE_URL && ENV.DATABASE_URL.trim() !== "") {
|
||||
// URL completa (recomendada p.ej. en Postgres/MariaDB)
|
||||
return new Sequelize(ENV.DATABASE_URL, common);
|
||||
}*/
|
||||
|
||||
// Parámetros sueltos (asegurar requeridos mínimos)
|
||||
if (!config.database.dialect) {
|
||||
throw new Error("DB_DIALECT is required when DATABASE_URL is not provided");
|
||||
}
|
||||
|
||||
if (!(config.database.name && config.database.user)) {
|
||||
throw new Error("DB_NAME and DB_USER are required when DATABASE_URL is not provided");
|
||||
}
|
||||
|
||||
return new Sequelize(config.database.name, config.database.user, config.database.password, {
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
dialect: config.database.dialect,
|
||||
...common,
|
||||
});
|
||||
await closeSequelize(sequelize, logger);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
const NodeEnvSchema = z.enum(["development", "test", "production"]);
|
||||
|
||||
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
|
||||
const DbDialectSchema = z.enum(["postgres", "mysql", "mariadb", "mssql", "sqlite"]);
|
||||
const DbDialectSchema = z.enum(["mysql", "mariadb"]);
|
||||
|
||||
export const CompanyCertificateSchema = z.object({
|
||||
certificateId: z.string().min(1),
|
||||
|
||||
@ -3,32 +3,30 @@ import os from "node:os";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationAuditContextProvider,
|
||||
SequelizeTransactionContext,
|
||||
} from "@erp/core/api";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createApp } from "./app.ts";
|
||||
import { initConfig } from "./config";
|
||||
import { tryConnectToDatabase } from "./config/database.ts";
|
||||
import { registerHealthRoutes } from "./health.ts";
|
||||
import { registerAuditRoutes } from "./infrastructure/audit/audit.routes";
|
||||
import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader";
|
||||
import {
|
||||
AuditChangeModel,
|
||||
AuditEventModel,
|
||||
OperationAuditContextProvider,
|
||||
SequelizeAuditStore,
|
||||
SequelizeTransactionContext,
|
||||
auditModelInitializers,
|
||||
} from "./infrastructure/audit/sequelize";
|
||||
import { listRoutes, logger } from "./lib/index.ts";
|
||||
import {
|
||||
getServiceScoped,
|
||||
initModules,
|
||||
listServices,
|
||||
registerModels,
|
||||
} from "./lib/modules/index.ts";
|
||||
startModules,
|
||||
} from "@erp/core/api";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
import { DateTime } from "luxon";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createApp } from "./app.ts";
|
||||
import { initConfig } from "./config";
|
||||
import { closeDatabase, tryConnectToDatabase } from "./config/database.ts";
|
||||
import { registerHealthRoutes } from "./health.ts";
|
||||
import { registerAuditRoutes } from "./infrastructure/audit/audit.routes";
|
||||
import { SequelizeAuditReader } from "./infrastructure/audit/audit-reader";
|
||||
import { listRoutes, logger } from "./lib/index.ts";
|
||||
import { registerModules } from "./register-modules.ts";
|
||||
|
||||
z.config(z.locales.es());
|
||||
@ -56,6 +54,7 @@ let isShuttingDown = false;
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
// 🔒 Bandera de readiness (se actualiza durante arranque y apagado)
|
||||
let isReady = false;
|
||||
let database: Sequelize | null = null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Manejo de cierre forzado del servidor (graceful shutdown + sockets)
|
||||
@ -166,6 +165,7 @@ const handleSignal = (signal: NodeJS.Signals) => async () => {
|
||||
logger.warn(`Received ${signal}. Starting graceful shutdown...`);
|
||||
try {
|
||||
await serverStop(server);
|
||||
if (database) await closeDatabase(database);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error("Error during shutdown, forcing exit(1)", { err, label: "handleSignal" });
|
||||
@ -202,6 +202,7 @@ process.on("unhandledRejection", async (reason: any, promise: Promise<any>) => {
|
||||
isReady = false;
|
||||
try {
|
||||
await serverStop(server);
|
||||
if (database) await closeDatabase(database);
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
@ -218,6 +219,7 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
isReady = false;
|
||||
try {
|
||||
await serverStop(server);
|
||||
if (database) await closeDatabase(database);
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
@ -233,7 +235,7 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
logger.info(`Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms`);
|
||||
logger.info(`Process PID: ${process.pid}`);
|
||||
|
||||
const database = await tryConnectToDatabase(config);
|
||||
database = await tryConnectToDatabase(config);
|
||||
|
||||
// Lógica de inicialización de DB, si procede:
|
||||
// initStructure(sequelizeConn.connection);
|
||||
@ -241,7 +243,7 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
|
||||
// ➕ Rutas de salud disponibles desde el inicio del proceso
|
||||
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
|
||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
||||
registerModels(auditModelInitializers, "server-infrastructure");
|
||||
const auditStore = new SequelizeAuditStore(
|
||||
AuditEventModel,
|
||||
AuditChangeModel,
|
||||
@ -254,6 +256,20 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
});
|
||||
|
||||
await initModules({
|
||||
config,
|
||||
app,
|
||||
database,
|
||||
logger,
|
||||
operationContext,
|
||||
transactionContext,
|
||||
audit,
|
||||
warmup: config.warmup,
|
||||
databaseSyncMode: config.database.syncMode,
|
||||
getService: () => {
|
||||
throw new Error("Services are only available from a module-scoped context.");
|
||||
},
|
||||
});
|
||||
await startModules({
|
||||
config,
|
||||
app,
|
||||
database,
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { AuditChangeModel, AuditEventModel } from "@erp/core/api";
|
||||
import type { AuditValue } from "@repo/rdx-audit";
|
||||
import type { Criteria, FiltersPrimitives } from "@repo/rdx-criteria/server";
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
|
||||
import { AuditChangeModel, AuditEventModel } from "./sequelize";
|
||||
|
||||
export type AuditReadAccess =
|
||||
| { readonly mode: "unrestricted" }
|
||||
| {
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
export * from "./audit-change.model";
|
||||
export * from "./audit-event.model";
|
||||
export * from "./sequelize-audit-entry.mapper";
|
||||
export * from "./sequelize-audit-store";
|
||||
|
||||
import { initializeAuditChangeModel } from "./audit-change.model";
|
||||
import { initializeAuditEventModel } from "./audit-event.model";
|
||||
|
||||
/** Initializers globales, registrados una vez por el runtime antes de initModels(). */
|
||||
export const auditModelInitializers = [initializeAuditEventModel, initializeAuditChangeModel];
|
||||
// Compatibilidad temporal para rutas HTTP y tests del servidor.
|
||||
// La persistencia reutilizable reside en @erp/core/api.
|
||||
export {
|
||||
AuditChangeModel,
|
||||
AuditEventModel,
|
||||
SequelizeAuditStore,
|
||||
auditModelInitializers,
|
||||
} from "@erp/core/api";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
||||
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
|
||||
import type { AuditEntry, AuditRecordOptions, IAuditStore } from "@repo/rdx-audit";
|
||||
import { AuditStoreError } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { ModelStatic } from "sequelize";
|
||||
@ -19,8 +19,8 @@ export class SequelizeAuditStore implements IAuditStore {
|
||||
private readonly transactionContext: ISequelizeTransactionContext
|
||||
) {}
|
||||
|
||||
async append(entry: AuditEntry): Promise<Result<void, AuditStoreError>> {
|
||||
const transaction = this.transactionContext.current();
|
||||
async append(entry: AuditEntry, options?: AuditRecordOptions): Promise<Result<void, AuditStoreError>> {
|
||||
const transaction = options?.transaction ?? this.transactionContext.current();
|
||||
if (entry.kind === "mutation" && !transaction) {
|
||||
return Result.fail(
|
||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { configureLogger, setLoggerSingleton } from "@repo/rdx-logger";
|
||||
|
||||
const logger = configureLogger("console"); // o según config/env
|
||||
const logger = configureLogger("console", { service: "server" });
|
||||
setLoggerSingleton(logger);
|
||||
|
||||
export { logger };
|
||||
|
||||
@ -1,216 +1,2 @@
|
||||
// apps/server/src/lib/modules/model-loader.ts
|
||||
import type { ModuleParams } from "@erp/core/api";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
type SequelizeLike = {
|
||||
sync: (options?: any) => Promise<void>;
|
||||
models: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ModelStatic = {
|
||||
name: string;
|
||||
associate?: (sequelize: Sequelize) => void;
|
||||
};
|
||||
|
||||
export type ModelInitializer = (sequelize: Sequelize) => ModelStatic;
|
||||
|
||||
type InitializerEntry = { init: ModelInitializer; moduleName: string };
|
||||
|
||||
type RegisteredModelEntry = {
|
||||
model: ModelStatic;
|
||||
moduleName: string;
|
||||
};
|
||||
|
||||
const allModelInitializers: InitializerEntry[] = [];
|
||||
const registeredModels: Map<string, RegisteredModelEntry> = new Map();
|
||||
|
||||
let modelsInitialized = false;
|
||||
|
||||
export const registerModels = (
|
||||
models: ModelInitializer[],
|
||||
_params?: ModuleParams,
|
||||
ctx?: { moduleName: string }
|
||||
) => {
|
||||
const moduleName = ctx?.moduleName ?? "unknown";
|
||||
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
logger.warn(`No models provided by module "${moduleName}"`, { label: "registerModels" });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const init of models) {
|
||||
allModelInitializers.push({ init, moduleName });
|
||||
}
|
||||
|
||||
logger.info(`${models.length} model initializer(s) enqueued from "${moduleName}"`, {
|
||||
label: "registerModels",
|
||||
});
|
||||
};
|
||||
|
||||
export const initModels = async (params: ModuleParams) => {
|
||||
if (modelsInitialized) {
|
||||
logger.warn("Models already initialized. Skipping initModels()", { label: "initModels" });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Init models...", { label: "initModels" });
|
||||
|
||||
const database = getDatabaseOrThrow(params);
|
||||
|
||||
try {
|
||||
defineRegisteredModels(database);
|
||||
associateRegisteredModels(database);
|
||||
await syncDatabase(database, getDatabaseSyncMode(params));
|
||||
|
||||
modelsInitialized = true;
|
||||
} catch (error) {
|
||||
registeredModels.clear();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getDatabaseOrThrow = (params: ModuleParams): SequelizeLike => {
|
||||
const { database } = params as { database?: SequelizeLike };
|
||||
|
||||
if (!database) {
|
||||
const error = new Error("Database not found.");
|
||||
logger.error(error.message, { label: "initModels" });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
const defineRegisteredModels = (database: SequelizeLike): void => {
|
||||
for (const entry of allModelInitializers) {
|
||||
defineModel(database, entry);
|
||||
}
|
||||
};
|
||||
|
||||
const defineModel = (database: SequelizeLike, entry: InitializerEntry): void => {
|
||||
const { init, moduleName } = entry;
|
||||
|
||||
try {
|
||||
const model = init(database as unknown as Sequelize);
|
||||
|
||||
assertValidModel(model);
|
||||
assertModelNameIsAvailable(model.name, moduleName);
|
||||
|
||||
registeredModels.set(model.name, { model, moduleName });
|
||||
|
||||
logger.info(`Model "${model.name}" registered (sequelize)`, {
|
||||
label: "registerModel",
|
||||
module: moduleName,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
logger.error(`Failed to define model (module="${moduleName}") : ${error.message}`, {
|
||||
label: "initModels",
|
||||
stack: error.stack,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const assertValidModel = (model: ModelStatic): void => {
|
||||
if (!model || typeof model.name !== "string" || !model.name) {
|
||||
throw new Error('Invalid model initializer: missing or empty "name"');
|
||||
}
|
||||
};
|
||||
|
||||
const assertModelNameIsAvailable = (modelName: string, moduleName: string): void => {
|
||||
const existing = registeredModels.get(modelName);
|
||||
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Model name collision: "${modelName}" from module "${moduleName}" conflicts with existing model from "${existing.moduleName}"`
|
||||
);
|
||||
};
|
||||
|
||||
const associateRegisteredModels = (database: SequelizeLike): void => {
|
||||
for (const entry of registeredModels.values()) {
|
||||
associateModel(database, entry);
|
||||
}
|
||||
};
|
||||
|
||||
const associateModel = (database: SequelizeLike, entry: RegisteredModelEntry): void => {
|
||||
const { model, moduleName } = entry;
|
||||
|
||||
if (typeof model.associate !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
model.associate(database as unknown as Sequelize);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
logger.error(
|
||||
`Failed to associate model "${model.name}" (module="${moduleName}") : ${error.message}`,
|
||||
{ label: "initModels", stack: error.stack }
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getDatabaseSyncMode = (params: ModuleParams): string => {
|
||||
return params.config.database.syncMode;
|
||||
};
|
||||
|
||||
const syncDatabase = async (database: SequelizeLike, syncMode: string): Promise<void> => {
|
||||
logger.info(`Database sync mode ${syncMode}`, { label: "initModels" });
|
||||
|
||||
try {
|
||||
if (syncMode === "none") {
|
||||
logger.info("Database sync skipped (mode=none)", { label: "initModels" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncMode === "alter") {
|
||||
await database.sync({ force: false, alter: true });
|
||||
logger.info("Database synchronized successfully (mode=alter).", { label: "initModels" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (syncMode === "force") {
|
||||
await database.sync({ force: true, alter: false });
|
||||
logger.warn("Database synchronized with FORCE (mode=force).", { label: "initModels" });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`Unknown DB sync mode "${String(syncMode)}" → skipping`, {
|
||||
label: "initModels",
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
logger.error("Error synchronizing database:", {
|
||||
error,
|
||||
label: "initModels",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRegisteredModels = () =>
|
||||
Array.from(registeredModels.entries()).map(([name, { moduleName }]) => ({
|
||||
name,
|
||||
moduleName,
|
||||
}));
|
||||
|
||||
export const resetModelsRegistry = () => {
|
||||
allModelInitializers.length = 0;
|
||||
registeredModels.clear();
|
||||
modelsInitialized = false;
|
||||
|
||||
logger.info("Model registry has been reset.", { label: "initModels" });
|
||||
};
|
||||
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
|
||||
export { registerModels, resetModuleRuntimeForTests as resetModelsRegistry } from "@erp/core/api";
|
||||
|
||||
@ -11,7 +11,7 @@ import { Audit } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { resetModelsRegistry } from "./model-loader";
|
||||
import { initModules, registerModule, resetModuleRegistry } from "./module-loader";
|
||||
import { initModules, registerModule, resetModuleRegistry, startModules } from "./module-loader";
|
||||
import { listServices, resetServiceRegistry } from "./service-registry";
|
||||
|
||||
type FakeDatabase = {
|
||||
@ -33,6 +33,8 @@ function buildModuleParams(): ModuleParams & {
|
||||
syncMode: "none",
|
||||
},
|
||||
},
|
||||
warmup: { timeoutMs: 10_000, strict: false },
|
||||
databaseSyncMode: "none",
|
||||
database: {
|
||||
sync: async () => undefined,
|
||||
models: {},
|
||||
@ -86,6 +88,7 @@ test("module-loader permite dependencias externas declaradas", async (t) => {
|
||||
registerModule(companiesModule);
|
||||
|
||||
await initModules(buildModuleParams());
|
||||
await startModules(buildModuleParams());
|
||||
|
||||
assert.equal(receivedService, "catalogs-service");
|
||||
assert.deepEqual(listServices().sort(), ["catalogs:general"]);
|
||||
@ -116,7 +119,10 @@ test("module-loader falla cuando una dependencia usada no está declarada", asyn
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => initModules(buildModuleParams()),
|
||||
async () => {
|
||||
await initModules(buildModuleParams());
|
||||
await startModules(buildModuleParams());
|
||||
},
|
||||
/without declaring dependency on "catalogs"/i
|
||||
);
|
||||
});
|
||||
@ -160,7 +166,10 @@ test("module-loader rechaza la autoreferencia explícita por nombre de módulo",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => initModules(buildModuleParams()),
|
||||
async () => {
|
||||
await initModules(buildModuleParams());
|
||||
await startModules(buildModuleParams());
|
||||
},
|
||||
/without declaring dependency on "companies"/i
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,374 +1,7 @@
|
||||
import type {
|
||||
IModuleServer,
|
||||
ModuleParams,
|
||||
ModuleSetupResult,
|
||||
SetupParams,
|
||||
StartParams,
|
||||
WarmupParams,
|
||||
/** Compatibilidad para imports internos históricos; el runtime vive en @erp/core/api. */
|
||||
export {
|
||||
initModules,
|
||||
registerModule,
|
||||
resetModuleRuntimeForTests as resetModuleRegistry,
|
||||
startModules,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
import { initModels, registerModels } from "./model-loader";
|
||||
import { getServiceScoped, listServices, registerService } from "./service-registry";
|
||||
|
||||
const registeredModules: Map<string, IModuleServer> = new Map();
|
||||
|
||||
// Nuevo: control por fases
|
||||
const setupDone = new Set<string>(); // para detección de ciclos
|
||||
const visiting = new Set<string>();
|
||||
const setupOrder: string[] = [];
|
||||
|
||||
// Internal store
|
||||
const internalByModule: Map<string, Record<string, unknown>> = new Map();
|
||||
|
||||
// Tracking de dependencias realmente usadas por módulo
|
||||
const usedDependenciesByModule: Map<string, Set<string>> = new Map();
|
||||
|
||||
// Warmup config (valores por defecto seguros)
|
||||
const WARMUP_TIMEOUT_MS = Number(process.env.WARMUP_TIMEOUT_MS) || 10_000;
|
||||
const WARMUP_STRICT =
|
||||
String(process.env.WARMUP_STRICT ?? "false")
|
||||
.toLowerCase()
|
||||
.trim() === "true";
|
||||
|
||||
/**
|
||||
- Registra un módulo del servidor en el registry.
|
||||
- Lanza error si el nombre ya existe.
|
||||
*/
|
||||
export function registerModule(pkg: IModuleServer) {
|
||||
if (!pkg?.name) throw new Error('❌ Invalid module: missing "name" property');
|
||||
if (registeredModules.has(pkg.name))
|
||||
throw new Error(`❌ Module "${pkg.name}" already registered`);
|
||||
registeredModules.set(pkg.name, pkg);
|
||||
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset del estado global del loader para entornos de test.
|
||||
*
|
||||
* En producción el proceso bootstrappea una sola vez. En tests HTTP necesitamos
|
||||
* poder recrear app + módulos + servicios varias veces dentro del mismo proceso.
|
||||
*/
|
||||
export function resetModuleRegistry(): void {
|
||||
registeredModules.clear();
|
||||
setupDone.clear();
|
||||
visiting.clear();
|
||||
setupOrder.length = 0;
|
||||
internalByModule.clear();
|
||||
usedDependenciesByModule.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
- Inicializa todos los módulos registrados (resolviendo dependencias),
|
||||
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
|
||||
*/
|
||||
export async function initModules(params: ModuleParams) {
|
||||
// 1) SETUP
|
||||
for (const name of registeredModules.keys()) {
|
||||
await setupModule(name, params, []); // secuencial para logs deterministas
|
||||
}
|
||||
|
||||
// 2) MODELS global
|
||||
await withPhase("global", "initModels", async () => await initModels(params));
|
||||
|
||||
// 3) START por orden de setup
|
||||
for (const name of setupOrder) {
|
||||
const pkg = registeredModules.get(name);
|
||||
if (!pkg?.start) continue;
|
||||
|
||||
// Si aún hay rastros del anterior init/registerDependencies en algún módulo:
|
||||
// - start() ≈ init() en el contrato nuevo
|
||||
// - setup() ≈ registerDependencies() en el contrato nuevo
|
||||
await withPhase(name, "start", async () => {
|
||||
const startParams: StartParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
};
|
||||
|
||||
// Aquí YA se permite getService/getInternal
|
||||
await Promise.resolve(pkg.start?.(startParams));
|
||||
});
|
||||
}
|
||||
|
||||
// 4) WARMUP opcional
|
||||
await warmupModules(params);
|
||||
|
||||
// 5) Validación dependencias usadas vs declaradas
|
||||
validateModuleDependencies();
|
||||
}
|
||||
|
||||
/**
|
||||
* SETUP recursivo: resuelve deps, ejecuta setup del módulo (antiguo "registerDependencies"),
|
||||
* registra modelos/servicios/internal, y guarda orden.
|
||||
*/
|
||||
async function setupModule(name: string, params: ModuleParams, stack: string[]) {
|
||||
if (setupDone.has(name)) return;
|
||||
|
||||
if (visiting.has(name)) {
|
||||
throw new Error(`❌ Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
|
||||
}
|
||||
|
||||
const pkg = registeredModules.get(name);
|
||||
if (!pkg)
|
||||
throw new Error(
|
||||
`❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})`
|
||||
);
|
||||
|
||||
visiting.add(name);
|
||||
stack.push(name);
|
||||
|
||||
// 1) deps first
|
||||
for (const dep of pkg.dependencies ?? []) {
|
||||
await setupModule(dep, params, stack.slice());
|
||||
}
|
||||
|
||||
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
|
||||
const pkgApi = await withPhase(name, "setup", async () => {
|
||||
const setupParams: SetupParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
listServices,
|
||||
};
|
||||
|
||||
return await Promise.resolve(pkg.setup?.(setupParams));
|
||||
});
|
||||
const moduleSetupResult: ModuleSetupResult | undefined = pkgApi;
|
||||
|
||||
// 3) internal store
|
||||
if (moduleSetupResult?.internal) {
|
||||
internalByModule.set(name, moduleSetupResult.internal);
|
||||
} else {
|
||||
internalByModule.set(name, {});
|
||||
}
|
||||
|
||||
// 4) models
|
||||
if (moduleSetupResult?.models) {
|
||||
const models = moduleSetupResult.models;
|
||||
|
||||
await withPhase(name, "registerModels", async () => {
|
||||
await Promise.resolve(registerModels(models, params, { moduleName: name }));
|
||||
});
|
||||
}
|
||||
|
||||
// 5) services (namespaced)
|
||||
if (moduleSetupResult?.services) {
|
||||
const services = moduleSetupResult.services;
|
||||
|
||||
await withPhase(name, "registerServices", async () => {
|
||||
validateModuleServices(name, services);
|
||||
|
||||
for (const [serviceKey, serviceApi] of Object.entries(services)) {
|
||||
const fullName = buildServiceName(name, serviceKey);
|
||||
registerService(fullName, serviceApi);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupDone.add(name);
|
||||
setupOrder.push(name);
|
||||
|
||||
visiting.delete(name);
|
||||
stack.pop();
|
||||
|
||||
logger.info(`✅ Module "${name}" setup done`, { label: "moduleRegistry" });
|
||||
}
|
||||
|
||||
/** getService instrumentado + scoped */
|
||||
function makeGetService(moduleName: string, pkg: IModuleServer) {
|
||||
return <T>(serviceName: string): T => {
|
||||
const [serviceModule] = serviceName.split(":");
|
||||
|
||||
// No registrar dependencias para modulos
|
||||
// que usan sus propios servicios.
|
||||
if (serviceModule !== "self") {
|
||||
trackDependencyUse(moduleName, serviceModule);
|
||||
}
|
||||
|
||||
// IMPORTANTE: devolver el valor
|
||||
return getServiceScoped<T>(moduleName, pkg.dependencies ?? [], serviceName);
|
||||
};
|
||||
}
|
||||
|
||||
/** getInternal: por defecto solo permite al propio módulo acceder a su internal */
|
||||
function makeGetInternal(requesterModule: string) {
|
||||
return <T>(moduleName: string, key?: string): T => {
|
||||
if (moduleName !== requesterModule) {
|
||||
throw new Error(
|
||||
`Module "${requesterModule}" attempted to access internal of "${moduleName}". ` +
|
||||
"Internal is private to the owning module."
|
||||
);
|
||||
}
|
||||
|
||||
const internal = internalByModule.get(moduleName) ?? {};
|
||||
if (!key) return internal as unknown as T;
|
||||
|
||||
if (!(key in internal)) {
|
||||
throw new Error(`Internal key "${key}" not found in module "${moduleName}".`);
|
||||
}
|
||||
return internal[key] as T;
|
||||
};
|
||||
}
|
||||
|
||||
function trackDependencyUse(requester: string, dep: string) {
|
||||
let set = usedDependenciesByModule.get(requester);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
usedDependenciesByModule.set(requester, set);
|
||||
}
|
||||
set.add(dep);
|
||||
}
|
||||
|
||||
function buildServiceName(moduleName: string, serviceKey: string): string {
|
||||
return `${moduleName}:${serviceKey}`;
|
||||
}
|
||||
|
||||
function validateModuleServices(moduleName: string, services: Record<string, unknown>) {
|
||||
for (const [serviceKey, serviceApi] of Object.entries(services)) {
|
||||
if (!serviceKey || typeof serviceKey !== "string") {
|
||||
throw new Error(`Invalid service key from module "${moduleName}"`);
|
||||
}
|
||||
|
||||
const fullName = `${moduleName}:${serviceKey}`;
|
||||
|
||||
if (serviceApi === undefined) {
|
||||
throw new Error(`Service "${fullName}" is undefined (validateModuleServices)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateModuleDependencies() {
|
||||
for (const [moduleName, pkg] of registeredModules.entries()) {
|
||||
const declared = new Set(pkg.dependencies ?? []);
|
||||
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
|
||||
|
||||
// ❌ usadas pero no declaradas
|
||||
const undeclaredUsed = [...used].filter((d) => !declared.has(d));
|
||||
|
||||
if (undeclaredUsed.length > 0) {
|
||||
throw new Error(
|
||||
`❌ Module "${moduleName}" used undeclared dependencies: ${undeclaredUsed.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// ⚠️ declaradas pero no usadas (opcional)
|
||||
const unusedDeclared = [...declared].filter((d) => !used.has(d));
|
||||
if (unusedDeclared.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ Module "${moduleName}" declared unused dependencies: ${unusedDeclared.join(", ")}`,
|
||||
{ label: "moduleRegistry", module: moduleName }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
- Ejecuta warmup() opcional de cada módulo en orden de inicialización.
|
||||
- Si WARMUP_STRICT=true, un fallo aborta el arranque; si no, se avisa y continúa.
|
||||
*/
|
||||
async function warmupModules(params: ModuleParams) {
|
||||
logger.info("🌡️ Warmup: starting...", { label: "moduleRegistry" });
|
||||
|
||||
for (const name of setupOrder) {
|
||||
const pkg = registeredModules.get(name);
|
||||
if (!pkg?.warmup) continue;
|
||||
|
||||
const maybeWarmup = pkg.warmup;
|
||||
if (typeof maybeWarmup !== "function") continue;
|
||||
|
||||
try {
|
||||
const warmupParams: WarmupParams = {
|
||||
...params,
|
||||
getService: makeGetService(name, pkg),
|
||||
getInternal: makeGetInternal(name),
|
||||
listServices,
|
||||
};
|
||||
|
||||
await withPhase(name, "warmup", () =>
|
||||
withTimeout(Promise.resolve(maybeWarmup(warmupParams)), WARMUP_TIMEOUT_MS, `${name}.warmup`)
|
||||
);
|
||||
} catch (error) {
|
||||
if (WARMUP_STRICT) {
|
||||
logger.error("⛔️ Warmup failed (strict=true). Aborting.", {
|
||||
label: "moduleRegistry",
|
||||
module: name,
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn("⚠️ Warmup failed but continuing (strict=false)", {
|
||||
label: "moduleRegistry",
|
||||
module: name,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("🌡️ Warmup: done.", { label: "moduleRegistry" });
|
||||
}
|
||||
|
||||
/**
|
||||
Helper para anotar fase y módulo en logs (inicio/fin/duración) y errores.
|
||||
*/
|
||||
async function withPhase<T>(
|
||||
moduleName: string,
|
||||
phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup",
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
logger.info(`▶️ phase=start module=${moduleName} ${phase}`, {
|
||||
label: "moduleRegistry",
|
||||
module: moduleName,
|
||||
phase,
|
||||
});
|
||||
|
||||
try {
|
||||
const out = await fn();
|
||||
const duration = Date.now() - startedAt;
|
||||
logger.info(`✅ phase=done module=${moduleName} ${phase} durationMs=${duration}`, {
|
||||
label: "moduleRegistry",
|
||||
module: moduleName,
|
||||
phase,
|
||||
durationMs: duration,
|
||||
});
|
||||
return out;
|
||||
} catch (err: unknown) {
|
||||
const duration = Date.now() - startedAt;
|
||||
const error = err as Error;
|
||||
|
||||
logger.error(
|
||||
`⛔️ Module "${moduleName}" failed at phase="${phase}" after ${duration}ms: ${error?.message ?? error}`,
|
||||
{
|
||||
label: "moduleRegistry",
|
||||
module: moduleName,
|
||||
phase,
|
||||
durationMs: duration,
|
||||
stack: error?.stack,
|
||||
}
|
||||
);
|
||||
// Re-lanzamos con contexto preservado
|
||||
const newError = new Error(
|
||||
`[module=${moduleName}] phase=${phase} failed: ${error?.message ?? "Unknown error"}`
|
||||
);
|
||||
newError.cause = error;
|
||||
throw newError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Promesa con timeout; rechaza si excede el tiempo dado.
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout after ${ms}ms (${label})`));
|
||||
}, ms).unref();
|
||||
});
|
||||
return Promise.race([p, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
@ -1,81 +1,6 @@
|
||||
const services: Record<string, unknown> = {};
|
||||
|
||||
/**
|
||||
* Registra un objeto de servicio (API) bajo un nombre.
|
||||
*/
|
||||
export function registerService(name: string, api: unknown) {
|
||||
console.debug(`Registering service: ${name}`);
|
||||
if (services[name]) {
|
||||
throw new Error(`❌ Servicio "${name}" ya fue registrado.`);
|
||||
}
|
||||
services[name] = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera un servicio registrado bajo un "scope".
|
||||
*
|
||||
* getService("customers:repository")
|
||||
* Debe declarar: dependencies: ["customers"]
|
||||
*
|
||||
* El "scope" puede ser "self" para recuperar
|
||||
* los servicios propios registrados.
|
||||
*
|
||||
* getService("self:repository")
|
||||
*/
|
||||
export function getServiceScoped<T = unknown>(
|
||||
requesterModule: string,
|
||||
allowedDeps: readonly string[],
|
||||
name: string
|
||||
): T {
|
||||
const [serviceModule, ...key] = name.split(":");
|
||||
|
||||
if (serviceModule === "self") {
|
||||
return getService<T>(`${requesterModule}:${key.join(":")}`);
|
||||
}
|
||||
|
||||
if (!allowedDeps.includes(serviceModule)) {
|
||||
throw new Error(
|
||||
`❌ Module "${requesterModule}" tried to access service "${name}" ` +
|
||||
`without declaring dependency on "${serviceModule}" (web/manifest.ts api/index.ts file)`
|
||||
);
|
||||
}
|
||||
|
||||
return getService<T>(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera un servicio registrado, con tipado opcional.
|
||||
*/
|
||||
function getService<T = unknown>(name: string): T {
|
||||
const service = services[name];
|
||||
if (!service) {
|
||||
throw new Error(`❌ Service "${name}" not found.`);
|
||||
}
|
||||
return service as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permite saber si un servicio fue registrado.
|
||||
*/
|
||||
export function hasService(name: string): boolean {
|
||||
return !!services[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve todos los servicios (para depuración o tests).
|
||||
*/
|
||||
export function listServices(): string[] {
|
||||
return Object.keys(services);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el registry entre arranques de test.
|
||||
*
|
||||
* El runtime productivo arranca una sola vez, pero los tests HTTP necesitan
|
||||
* reinicializar módulos y servicios sin contaminar el siguiente caso.
|
||||
*/
|
||||
export function resetServiceRegistry(): void {
|
||||
for (const name of Object.keys(services)) {
|
||||
delete services[name];
|
||||
}
|
||||
}
|
||||
/** Compatibilidad para imports internos históricos; el registry vive en @erp/core/api. */
|
||||
export {
|
||||
getServiceScoped,
|
||||
listServices,
|
||||
resetModuleRuntimeForTests as resetServiceRegistry,
|
||||
} from "@erp/core/api";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import catalogsAPIModule from "@erp/catalogs/api";
|
||||
import companiesAPIModule from "@erp/companies/api";
|
||||
import { registerModule } from "@erp/core/api";
|
||||
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
|
||||
import customersAPIModule from "@erp/customers/api";
|
||||
import documentSeriesAPIModule from "@erp/document-series/api";
|
||||
@ -7,8 +8,6 @@ import factuGESAPIModule from "@erp/factuges/api";
|
||||
import identityAPIModule from "@erp/identity/api";
|
||||
import suppliersAPIModule from "@erp/suppliers/api";
|
||||
|
||||
import { registerModule } from "./lib";
|
||||
|
||||
// Aquí hay que registrar los módulos que
|
||||
// queramos que se carguen en el servidor.
|
||||
// El registro hace que estén disponibles
|
||||
|
||||
@ -10,6 +10,13 @@ import {
|
||||
type ISequelizeTransactionContext,
|
||||
OperationAuditContextProvider,
|
||||
SequelizeTransactionContext,
|
||||
getServiceScoped,
|
||||
initModules,
|
||||
listServices,
|
||||
registerModels,
|
||||
registerModule,
|
||||
resetModuleRuntimeForTests,
|
||||
startModules,
|
||||
} from "@erp/core/api";
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
@ -32,16 +39,6 @@ import {
|
||||
auditModelInitializers,
|
||||
} from "@/infrastructure/audit/sequelize";
|
||||
import { logger } from "@/lib/logger.ts";
|
||||
import {
|
||||
getServiceScoped,
|
||||
initModules,
|
||||
listServices,
|
||||
registerModels,
|
||||
registerModule,
|
||||
resetModelsRegistry,
|
||||
resetModuleRegistry,
|
||||
resetServiceRegistry,
|
||||
} from "@/lib/modules";
|
||||
import { registerModules } from "@/register-modules.ts";
|
||||
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
@ -114,8 +111,22 @@ export async function createHttpTestRuntime(
|
||||
for (const testModule of options.testModules ?? []) {
|
||||
registerModule(testModule);
|
||||
}
|
||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
||||
registerModels(auditModelInitializers, "server-infrastructure");
|
||||
await initModules({
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
logger,
|
||||
operationContext,
|
||||
transactionContext,
|
||||
audit,
|
||||
warmup: config.warmup,
|
||||
databaseSyncMode: config.database.syncMode,
|
||||
getService: () => {
|
||||
throw new Error("Services are only available from a module-scoped context.");
|
||||
},
|
||||
});
|
||||
await startModules({
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
@ -207,9 +218,7 @@ function loadTestEnvironment(): void {
|
||||
|
||||
function resetServerRuntimeForTests(): void {
|
||||
resetConfigForTests();
|
||||
resetServiceRegistry();
|
||||
resetModuleRegistry();
|
||||
resetModelsRegistry();
|
||||
resetModuleRuntimeForTests();
|
||||
}
|
||||
|
||||
function assignUniqueTestDatabaseName(): void {
|
||||
|
||||
@ -58,6 +58,7 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{ title: "Proformas recurrentes", href: "/recurring-proformas", icon: FileTextIcon },
|
||||
{ title: "Clientes", href: "/customers", icon: UsersIcon },
|
||||
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },
|
||||
],
|
||||
|
||||
@ -88,6 +88,9 @@ export const AppTopbar = () => {
|
||||
<DropdownMenuItem onClick={() => navigate("/customers/create")}>
|
||||
{t("web.home.actions.newCustomer")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate("/suppliers/create")}>
|
||||
{t("web.home.actions.newSupplier")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -56,7 +56,8 @@
|
||||
"actions": {
|
||||
"new": "New",
|
||||
"newProforma": "New proforma",
|
||||
"newCustomer": "New customer"
|
||||
"newCustomer": "New customer",
|
||||
"newSupplier": "New supplier"
|
||||
},
|
||||
"kpis": {
|
||||
"customers": {
|
||||
|
||||
@ -56,7 +56,8 @@
|
||||
"actions": {
|
||||
"new": "Nuevo",
|
||||
"newProforma": "Nueva proforma",
|
||||
"newCustomer": "Nuevo cliente"
|
||||
"newCustomer": "Nuevo cliente",
|
||||
"newSupplier": "Nuevo proveedor"
|
||||
},
|
||||
"kpis": {
|
||||
"customers": {
|
||||
|
||||
39
apps/worker/package.json
Normal file
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",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true
|
||||
"bracketSameLine": true,
|
||||
"expand": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@
|
||||
".": "./src/common/index.ts",
|
||||
"./common": "./src/common/index.ts",
|
||||
"./api": "./src/api/index.ts",
|
||||
"./module": "./src/api/module.ts",
|
||||
"./client": "./src/web/manifest.ts",
|
||||
"./client/*": "./src/web/*/index.ts"
|
||||
},
|
||||
@ -53,4 +54,4 @@
|
||||
"use-debounce": "^10.1.1",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,14 @@ export interface FindActiveTaxDefinitionByCodeParams {
|
||||
companyId: UniqueID;
|
||||
code: string;
|
||||
atDate: UtcDate;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface FindActiveTaxDefinitionsByCodesParams {
|
||||
companyId: UniqueID;
|
||||
codes: Collection<string>;
|
||||
atDate: UtcDate;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface ITaxDefinitionPublicFinder {
|
||||
|
||||
@ -1,101 +1,23 @@
|
||||
import type { IModuleServer } from "@erp/core/api";
|
||||
import { paymentMethodsRouter } from "./infrastructure/payment-methods/express";
|
||||
import { paymentTermsRouter } from "./infrastructure/payment-terms/express";
|
||||
import { taxDefinitionsRouter } from "./infrastructure/tax-definitions/express";
|
||||
import { taxRegimesRouter } from "./infrastructure/tax-regimes/express";
|
||||
import { catalogsModule } from "./module";
|
||||
|
||||
import {
|
||||
paymentMethodModels,
|
||||
paymentMethodsRouter,
|
||||
paymentTermModels,
|
||||
paymentTermsRouter,
|
||||
taxDefinitionModels,
|
||||
taxDefinitionsRouter,
|
||||
taxRegimeModels,
|
||||
taxRegimesRouter,
|
||||
} from "./infrastructure";
|
||||
import {
|
||||
buildCatalogsDependencies,
|
||||
buildCatalogsPublicServices,
|
||||
} from "./infrastructure/di/catalogs.di";
|
||||
export * from "./module";
|
||||
|
||||
export * from "./application/payment-methods/public";
|
||||
export * from "./application/payment-terms/public";
|
||||
export * from "./application/tax-definitions/public";
|
||||
export * from "./application/tax-regimes/public";
|
||||
|
||||
//export * from "./infrastructure/payment-methods/persistence/sequelize"; <- ???
|
||||
|
||||
export type CatalogsPublicServicesType = ReturnType<typeof buildCatalogsPublicServices>;
|
||||
|
||||
export const catalogsAPIModule: IModuleServer = {
|
||||
name: "catalogs",
|
||||
version: "1.0.0",
|
||||
dependencies: ["identity"],
|
||||
|
||||
/**
|
||||
* Fase de SETUP
|
||||
* ----------------
|
||||
* - Construye el dominio (una sola vez)
|
||||
* - Define qué expone el módulo
|
||||
* - NO conecta infraestructura
|
||||
*/
|
||||
async setup(params) {
|
||||
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
|
||||
|
||||
// 1) Dominio interno
|
||||
const internal = buildCatalogsDependencies(params);
|
||||
|
||||
// 2) Servicios públicos (Application Services)
|
||||
const publicServices = buildCatalogsPublicServices(params, internal);
|
||||
|
||||
logger.info("🚀 Catalogs module dependencies registered", {
|
||||
label: this.name,
|
||||
});
|
||||
|
||||
return {
|
||||
// Modelos Sequelize del módulo
|
||||
models: [
|
||||
...paymentMethodModels,
|
||||
...paymentTermModels,
|
||||
...taxDefinitionModels,
|
||||
...taxRegimeModels,
|
||||
],
|
||||
|
||||
// Servicios expuestos a otros módulos
|
||||
services: {
|
||||
...publicServices,
|
||||
},
|
||||
|
||||
// Implementación privada del módulo
|
||||
internal,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fase de START
|
||||
* -------------
|
||||
* - Conecta el módulo al runtime
|
||||
* - Puede usar servicios e internals ya construidos
|
||||
* - NO construye dominio
|
||||
*/
|
||||
async start(params) {
|
||||
const { logger } = params;
|
||||
|
||||
// Registro de rutas HTTP
|
||||
/** Integración HTTP sobre el manifest neutral, sin reconstruir dependencias. */
|
||||
export const catalogsAPIModule = {
|
||||
...catalogsModule,
|
||||
async start(params: StartParams) {
|
||||
paymentMethodsRouter(params);
|
||||
paymentTermsRouter(params);
|
||||
taxRegimesRouter(params);
|
||||
taxDefinitionsRouter(params);
|
||||
|
||||
logger.info("🚀 Catalogs module started", {
|
||||
label: this.name,
|
||||
});
|
||||
params.logger.info("Catalogs module started", { label: catalogsModule.name });
|
||||
},
|
||||
|
||||
/**
|
||||
* Warmup opcional (si lo necesitas en el futuro)
|
||||
* ----------------------------------------------
|
||||
* warmup(params) {
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
};
|
||||
|
||||
export default catalogsAPIModule;
|
||||
|
||||
import type { StartParams } from "@erp/core/api";
|
||||
|
||||
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",
|
||||
"./common": "./src/common/index.ts",
|
||||
"./api": "./src/api/index.ts",
|
||||
"./module": "./src/api/module.ts",
|
||||
"./client": "./src/web/manifest.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -50,4 +51,4 @@
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,78 +1,17 @@
|
||||
import type { IModuleServer } from "@erp/core/api";
|
||||
import type { StartParams } from "@erp/core/api";
|
||||
|
||||
import type { ICompanyPublicServices } from "./application";
|
||||
import { companiesRouter, models } from "./infrastructure";
|
||||
import { buildCompaniesDependencies, buildCompaniesPublicServices } from "./infrastructure/di";
|
||||
import { companiesRouter } from "./infrastructure/express";
|
||||
import { companiesModule } from "./module";
|
||||
|
||||
export type { ICompanyPublicServices } from "./application";
|
||||
export * from "./infrastructure/persistence/sequelize";
|
||||
export * from "./module";
|
||||
|
||||
export type CompaniesPublicServicesType = ICompanyPublicServices;
|
||||
|
||||
export const companiesAPIModule: IModuleServer = {
|
||||
name: "companies",
|
||||
version: "1.0.0",
|
||||
dependencies: ["identity", "catalogs"],
|
||||
|
||||
/**
|
||||
* Fase de SETUP
|
||||
* ----------------
|
||||
* - Construye el dominio (una sola vez)
|
||||
* - Define qué expone el módulo
|
||||
* - NO conecta infraestructura
|
||||
*/
|
||||
async setup(params) {
|
||||
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
|
||||
|
||||
const internal = buildCompaniesDependencies(params);
|
||||
const companiesServices: ICompanyPublicServices = buildCompaniesPublicServices(
|
||||
params,
|
||||
internal
|
||||
);
|
||||
|
||||
logger.info("🚀 Companies module dependencies registered", {
|
||||
label: this.name,
|
||||
});
|
||||
|
||||
return {
|
||||
// Modelos Sequelize del módulo
|
||||
models,
|
||||
|
||||
// Servicios expuestos a otros módulos
|
||||
services: {
|
||||
general: companiesServices, // 'companies:general'
|
||||
},
|
||||
|
||||
// Implementación privada del módulo
|
||||
internal,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Fase de START
|
||||
* -------------
|
||||
* - Conecta el módulo al runtime
|
||||
* - Puede usar servicios e internals ya construidos
|
||||
* - NO construye dominio
|
||||
*/
|
||||
async start(params) {
|
||||
const { app, baseRoutePath, logger, getInternal } = params;
|
||||
|
||||
// Registro de rutas HTTP
|
||||
/** Integración HTTP sobre el manifest neutral de compañías. */
|
||||
export const companiesAPIModule = {
|
||||
...companiesModule,
|
||||
async start(params: StartParams) {
|
||||
companiesRouter(params);
|
||||
|
||||
logger.info("🚀 Companies module started", {
|
||||
label: this.name,
|
||||
});
|
||||
params.logger.info("Companies module started", { label: companiesModule.name });
|
||||
},
|
||||
|
||||
/**
|
||||
* Warmup opcional (si lo necesitas en el futuro)
|
||||
* ----------------------------------------------
|
||||
* warmup(params) {
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
};
|
||||
|
||||
export default companiesAPIModule;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { type ModuleParams, type SetupParams, buildTransactionManager } from "@erp/core/api";
|
||||
import type { IIdentityPublicServices } from "@erp/identity/api";
|
||||
import type { IIdentityCompanyAccessChecker } from "@erp/identity/module";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import {
|
||||
@ -44,7 +44,9 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
||||
const { audit, database, getService, transactionContext } = params;
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const responseMapper = new CompanyPublicModelMapper();
|
||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
||||
const companyAccess = getService<{ companyAccess: IIdentityCompanyAccessChecker }>(
|
||||
"identity:general"
|
||||
).companyAccess;
|
||||
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
||||
|
||||
const repository = new SequelizeCompanyRepository(new SequelizeCompanyDomainMapper(), database);
|
||||
@ -84,7 +86,7 @@ export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInter
|
||||
}),
|
||||
findAvailableCompaniesForAccount: () =>
|
||||
new FindAvailableCompaniesForAccountUseCase({
|
||||
identityServices,
|
||||
identityServices: { companyAccess },
|
||||
repository,
|
||||
responseMapper,
|
||||
transactionManager,
|
||||
|
||||
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 "./audit";
|
||||
export * from "./context";
|
||||
export * from "./database";
|
||||
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 "./database-config";
|
||||
export * from "./sequelize-factory";
|
||||
export * from "./sequelize-error-translator";
|
||||
export * from "./sequelize-func";
|
||||
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-runtime";
|
||||
export * from "./modules-runtime";
|
||||
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",
|
||||
"./common": "./src/common/index.ts",
|
||||
"./api": "./src/api/index.ts",
|
||||
"./api/application": "./src/api/application/index.ts",
|
||||
"./api/module": "./src/api/module.ts",
|
||||
"./api/domain": "./src/api/domain/index.ts",
|
||||
"./client": "./src/web/manifest.ts",
|
||||
"./globals.css": "./src/web/globals.css"
|
||||
@ -70,4 +72,4 @@
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,3 +2,4 @@ export * from "./company-verifactu-profile";
|
||||
export * from "./invoice-series";
|
||||
export * from "./issued-invoices";
|
||||
export * from "./proformas";
|
||||
export * from "./recurring-proformas";
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type ProformaSeriesValidator,
|
||||
type ProformaTargetInvoiceSeriesValidator,
|
||||
type ProformaTaxResolver,
|
||||
ProformaTaxConfigResolver,
|
||||
} from "../services";
|
||||
|
||||
export const buildProformaCreator = (deps: {
|
||||
@ -23,6 +24,10 @@ export const buildProformaCreator = (deps: {
|
||||
companiesServices: ICompanyPublicServices;
|
||||
customerServices: ICustomerPublicServices;
|
||||
audit: IAudit;
|
||||
taxConfigResolver?: ProformaTaxConfigResolver;
|
||||
}): IProformaCreator => {
|
||||
return new ProformaCreator(deps);
|
||||
return new ProformaCreator({
|
||||
...deps,
|
||||
taxConfigResolver: deps.taxConfigResolver ?? new ProformaTaxConfigResolver(deps.taxResolver),
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./build-proforma-tax-resolver";
|
||||
export * from "./proforma-payment-resolver";
|
||||
export * from "./proforma-tax-resolver";
|
||||
export * from "./proforma-tax-config-resolver";
|
||||
|
||||
@ -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 {
|
||||
companyId: UniqueID;
|
||||
code: Maybe<string>;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface EnsureProformaItemTaxesParams {
|
||||
companyId: UniqueID;
|
||||
atDate: UtcDate;
|
||||
taxCodes: ProformaItemTaxCodesInput;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
export interface FindActiveProformaTaxDefinitionByCodeParams {
|
||||
companyId: UniqueID;
|
||||
atDate: UtcDate;
|
||||
code: string;
|
||||
transaction?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -53,10 +56,9 @@ export class ProformaTaxResolver {
|
||||
) {}
|
||||
|
||||
// Solo asegurarnos que existe un TaxRegime con ese "code"
|
||||
public async ensureTaxRegimeByCode(params: {
|
||||
companyId: UniqueID;
|
||||
code: Maybe<string>;
|
||||
}): Promise<Result<void, Error>> {
|
||||
public async ensureTaxRegimeByCode(
|
||||
params: EnsureProformaTaxRegimeByCodeParams
|
||||
): Promise<Result<void, Error>> {
|
||||
if (params.code.isNone()) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
@ -66,6 +68,7 @@ export class ProformaTaxResolver {
|
||||
const result = await this.deps.taxRegimeFinder.findByCodeInCompany({
|
||||
companyId: params.companyId,
|
||||
code,
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (result.isFailure) {
|
||||
@ -92,6 +95,7 @@ export class ProformaTaxResolver {
|
||||
companyId: params.companyId,
|
||||
codes,
|
||||
atDate: params.atDate,
|
||||
transaction: params.transaction,
|
||||
});
|
||||
|
||||
if (definitions.isFailure) {
|
||||
@ -128,6 +132,7 @@ export class ProformaTaxResolver {
|
||||
companyId: params.companyId,
|
||||
code: params.code,
|
||||
atDate: params.atDate,
|
||||
transaction: params.transaction,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -10,18 +10,18 @@ import {
|
||||
InvoiceNumber,
|
||||
InvoiceSerie,
|
||||
Proforma,
|
||||
ProformaTaxConfig,
|
||||
} from "../../../domain";
|
||||
import {
|
||||
PROFORMA_AUDIT_ACTIONS,
|
||||
toProformaAuditState,
|
||||
toProformaAuditTarget,
|
||||
} from "../audit/proforma-audit";
|
||||
import type { ProformaCreateInputProps, ProformaTaxConfigInputProps } from "../models";
|
||||
import type { ProformaCreateInputProps } from "../models";
|
||||
import type { IProformaRepository } from "../repositories";
|
||||
|
||||
import type { ProformaPaymentResolver } from "./catalog-resolver/proforma-payment-resolver";
|
||||
import type { ProformaTaxResolver } from "./catalog-resolver/proforma-tax-resolver";
|
||||
import { ProformaTaxConfigResolver } from "./catalog-resolver/proforma-tax-config-resolver";
|
||||
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
|
||||
import type { ProformaSeriesValidator } from "./proforma-series-validator.interface";
|
||||
import type { ProformaTargetInvoiceSeriesValidator } from "./proforma-target-invoice-series-validator.interface";
|
||||
@ -43,6 +43,7 @@ export class ProformaCreator implements IProformaCreator {
|
||||
repository: IProformaRepository;
|
||||
numberService: IProformaNumberGenerator;
|
||||
taxResolver: ProformaTaxResolver;
|
||||
taxConfigResolver?: ProformaTaxConfigResolver;
|
||||
paymentResolver: ProformaPaymentResolver;
|
||||
proformaSeriesValidator: ProformaSeriesValidator;
|
||||
targetInvoiceSeriesValidator: ProformaTargetInvoiceSeriesValidator;
|
||||
@ -213,6 +214,7 @@ export class ProformaCreator implements IProformaCreator {
|
||||
const taxRegimeResult = await this.deps.taxResolver.ensureTaxRegimeByCode({
|
||||
companyId: _newProps.companyId,
|
||||
code: _newProps.taxRegimeCode,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (taxRegimeResult.isFailure) {
|
||||
@ -230,11 +232,13 @@ export class ProformaCreator implements IProformaCreator {
|
||||
return Result.fail(paymentMethodResult.error);
|
||||
}
|
||||
|
||||
const taxConfigResult = await this.resolveTaxConfig({
|
||||
const taxConfigResolver = this.deps.taxConfigResolver ?? new ProformaTaxConfigResolver(this.deps.taxResolver);
|
||||
const taxConfigResult = await taxConfigResolver.resolve({
|
||||
companyId: _newProps.companyId,
|
||||
atDate: _newProps.proformaDate,
|
||||
customer,
|
||||
override: props.taxConfig,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (taxConfigResult.isFailure) {
|
||||
@ -251,6 +255,7 @@ export class ProformaCreator implements IProformaCreator {
|
||||
companyId: _newProps.companyId,
|
||||
atDate: _newProps.proformaDate,
|
||||
taxCodes: item.taxes,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (taxes.isFailure) {
|
||||
@ -354,172 +359,4 @@ export class ProformaCreator implements IProformaCreator {
|
||||
return Result.ok(resolvedDefaultPaymentTermId);
|
||||
}
|
||||
|
||||
private async resolveTaxConfig(params: {
|
||||
companyId: UniqueID;
|
||||
atDate: IProformaCreateProps["proformaDate"];
|
||||
customer: Awaited<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
|
||||
infer T,
|
||||
Error
|
||||
>
|
||||
? T
|
||||
: never;
|
||||
override?: ProformaTaxConfigInputProps;
|
||||
}): Promise<Result<ProformaTaxConfig, Error>> {
|
||||
const taxMode = params.override?.taxMode ?? "single";
|
||||
const defaultIvaCode =
|
||||
params.override?.defaultIvaCode ??
|
||||
Maybe.some(this.resolveDefaultIvaCodeFromCustomer(params.customer) ?? "iva_21");
|
||||
|
||||
const usesEquivalenceSurcharge =
|
||||
params.override?.usesEquivalenceSurcharge ?? params.customer.usesEquivalenceSurcharge;
|
||||
const usesRetention = params.override?.usesRetention ?? params.customer.usesRetention;
|
||||
|
||||
const defaultRecCode = await this.resolveDefaultRecCode({
|
||||
companyId: params.companyId,
|
||||
atDate: params.atDate,
|
||||
defaultIvaCode,
|
||||
usesEquivalenceSurcharge,
|
||||
requestedCode: params.override?.defaultRecCode,
|
||||
});
|
||||
|
||||
if (defaultRecCode.isFailure) {
|
||||
return Result.fail(defaultRecCode.error);
|
||||
}
|
||||
|
||||
const defaultRetentionCode = await this.resolveRetentionCode({
|
||||
companyId: params.companyId,
|
||||
atDate: params.atDate,
|
||||
usesRetention,
|
||||
requestedCode: params.override?.defaultRetentionCode,
|
||||
});
|
||||
|
||||
if (defaultRetentionCode.isFailure) {
|
||||
return Result.fail(defaultRetentionCode.error);
|
||||
}
|
||||
|
||||
const createdTaxConfig = ProformaTaxConfig.create({
|
||||
taxMode,
|
||||
defaultIvaCode,
|
||||
usesEquivalenceSurcharge,
|
||||
defaultRecCode: defaultRecCode.data,
|
||||
usesRetention,
|
||||
defaultRetentionCode: defaultRetentionCode.data,
|
||||
});
|
||||
|
||||
if (createdTaxConfig.isFailure) {
|
||||
return Result.fail(createdTaxConfig.error);
|
||||
}
|
||||
|
||||
return Result.ok(createdTaxConfig.data);
|
||||
}
|
||||
|
||||
private resolveDefaultIvaCodeFromCustomer(
|
||||
_customer: Awaited<ReturnType<ICustomerPublicServices["getCustomerById"]>> extends Result<
|
||||
infer T,
|
||||
Error
|
||||
>
|
||||
? T
|
||||
: never
|
||||
): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async resolveDefaultRecCode(params: {
|
||||
companyId: UniqueID;
|
||||
atDate: IProformaCreateProps["proformaDate"];
|
||||
defaultIvaCode: Maybe<string>;
|
||||
usesEquivalenceSurcharge: boolean;
|
||||
requestedCode?: Maybe<string>;
|
||||
}): Promise<Result<Maybe<string>, Error>> {
|
||||
if (!params.usesEquivalenceSurcharge) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
const ivaCode = this.maybeValue(params.defaultIvaCode);
|
||||
|
||||
if (!ivaCode) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
const ivaDefinition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({
|
||||
companyId: params.companyId,
|
||||
atDate: params.atDate,
|
||||
code: ivaCode,
|
||||
});
|
||||
|
||||
if (ivaDefinition.isFailure) {
|
||||
return Result.fail(ivaDefinition.error);
|
||||
}
|
||||
|
||||
if (ivaDefinition.data.isNone()) {
|
||||
return Result.fail(new Error(`Tax definition not found: ${ivaCode}`));
|
||||
}
|
||||
|
||||
const allowedSurchargeCodes = ivaDefinition.data.unwrap().allowedSurchargeCodes.getAll();
|
||||
const requestedCode = this.maybeValue(params.requestedCode) ?? this.defaultRecForIva(ivaCode);
|
||||
|
||||
if (requestedCode && allowedSurchargeCodes.includes(requestedCode)) {
|
||||
return Result.ok(Maybe.some(requestedCode));
|
||||
}
|
||||
|
||||
const fallbackCode = allowedSurchargeCodes[0] ?? null;
|
||||
|
||||
return Result.ok(fallbackCode ? Maybe.some(fallbackCode) : Maybe.none());
|
||||
}
|
||||
|
||||
private async resolveRetentionCode(params: {
|
||||
companyId: UniqueID;
|
||||
atDate: IProformaCreateProps["proformaDate"];
|
||||
usesRetention: boolean;
|
||||
requestedCode?: Maybe<string>;
|
||||
}): Promise<Result<Maybe<string>, Error>> {
|
||||
if (!params.usesRetention) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
|
||||
const retentionCode = this.maybeValue(params.requestedCode) ?? "retention_15";
|
||||
|
||||
const definition = await this.deps.taxResolver.findActiveTaxDefinitionByCode({
|
||||
companyId: params.companyId,
|
||||
atDate: params.atDate,
|
||||
code: retentionCode,
|
||||
});
|
||||
|
||||
if (definition.isFailure) {
|
||||
return Result.fail(definition.error);
|
||||
}
|
||||
|
||||
if (definition.data.isNone()) {
|
||||
return Result.fail(new Error(`Tax definition not found: ${retentionCode}`));
|
||||
}
|
||||
|
||||
return Result.ok(Maybe.some(retentionCode));
|
||||
}
|
||||
|
||||
private defaultRecForIva(ivaCode: string): string | null {
|
||||
const defaults: Record<string, string> = {
|
||||
iva_21: "rec_5_2",
|
||||
iva_10: "rec_1_4",
|
||||
iva_4: "rec_0_5",
|
||||
iva_0: "rec_0",
|
||||
};
|
||||
|
||||
return defaults[ivaCode] ?? null;
|
||||
}
|
||||
|
||||
private maybeValue(value: Maybe<string> | string | null | undefined): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "object" && "isSome" in value && typeof value.isSome === "function") {
|
||||
return value.isSome() ? value.unwrap() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 "./issued-invoices";
|
||||
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,
|
||||
models,
|
||||
proformasRouter,
|
||||
recurringProformasRouter,
|
||||
} from "./infrastructure";
|
||||
|
||||
export type { IProformaPublicServices } from "./application";
|
||||
@ -73,6 +74,7 @@ export const customerInvoicesAPIModule: IModuleServer = {
|
||||
companyVerifactuProfileRouter(params);
|
||||
issuedInvoicesRouter(params);
|
||||
proformasRouter(params);
|
||||
recurringProformasRouter(params);
|
||||
|
||||
logger.info("🚀 CustomerInvoices module started", {
|
||||
label: this.name,
|
||||
|
||||
@ -6,6 +6,9 @@ import proformaModelInit from "./models/sequelize-proforma.model";
|
||||
import proformaItemModelInit from "./models/sequelize-proforma-item.model";
|
||||
import proformaTaxModelInit from "./models/sequelize-proforma-tax.model";
|
||||
import verifactuRecordModelInit from "./models/verifactu-record.model";
|
||||
import recurringProformaModelInit from "./models/recurring-proforma.model";
|
||||
import recurringProformaItemModelInit from "./models/recurring-proforma-item.model";
|
||||
import recurringProformaOccurrenceModelInit from "./models/recurring-proforma-occurrence.model";
|
||||
|
||||
export * from "./models";
|
||||
|
||||
@ -21,4 +24,7 @@ export const models = [
|
||||
issuedInvoiceTaxModelInit,
|
||||
|
||||
verifactuRecordModelInit,
|
||||
recurringProformaModelInit,
|
||||
recurringProformaItemModelInit,
|
||||
recurringProformaOccurrenceModelInit,
|
||||
];
|
||||
|
||||
@ -6,3 +6,6 @@ export * from "./sequelize-issued-invoice.model";
|
||||
export * from "./sequelize-issued-invoice-item.model";
|
||||
export * from "./sequelize-issued-invoice-tax.model";
|
||||
export * from "./verifactu-record.model";
|
||||
export * from "./recurring-proforma.model";
|
||||
export * from "./recurring-proforma-item.model";
|
||||
export * from "./recurring-proforma-occurrence.model";
|
||||
|
||||
@ -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