From a746bb97ec7e587c0bfd9b34b547103fb67373be Mon Sep 17 00:00:00 2001 From: david Date: Mon, 31 Aug 2026 20:51:27 +0200 Subject: [PATCH] Proformas recurrentes --- apps/server/src/config/config.ts | 21 +- apps/server/src/config/database.ts | 87 +- apps/server/src/config/env-schema.ts | 2 +- apps/server/src/index.ts | 56 +- .../src/infrastructure/audit/audit-reader.ts | 3 +- .../infrastructure/audit/sequelize/index.ts | 18 +- .../audit/sequelize/sequelize-audit-store.ts | 6 +- apps/server/src/lib/logger.ts | 2 +- apps/server/src/lib/modules/model-loader.ts | 218 +- .../src/lib/modules/module-loader.test.ts | 15 +- apps/server/src/lib/modules/module-loader.ts | 379 +- .../src/lib/modules/service-registry.ts | 87 +- apps/server/src/register-modules.ts | 3 +- .../src/testing/http/http-test-runtime.ts | 37 +- apps/web/src/layout/app-sidebar.config.ts | 1 + apps/web/src/layout/app-topbar.tsx | 3 + apps/web/src/locales/en.json | 3 +- apps/web/src/locales/es.json | 3 +- apps/worker/package.json | 39 + apps/worker/src/config/worker-config.ts | 18 + apps/worker/src/config/worker-env-schema.ts | 18 + apps/worker/src/index.ts | 44 + .../jobs/recurring-proforma-scheduled-job.ts | 44 + apps/worker/src/modules/register-modules.ts | 17 + apps/worker/src/scheduler/register-jobs.ts | 28 + apps/worker/src/scheduler/scheduled-job.ts | 1 + apps/worker/src/scheduler/scheduler.test.ts | 26 + apps/worker/src/scheduler/scheduler.ts | 45 + apps/worker/tsconfig.json | 6 + apps/worker/tsup.config.ts | 3 + biome.json | 3 +- ...CUMENTO DE DISEÑO — recurring-proformas.md | 3542 +++++++++++++++++ modules/catalogs/package.json | 3 +- .../tax-definition-public-finder.interface.ts | 2 + modules/catalogs/src/api/index.ts | 104 +- modules/catalogs/src/api/module.test.ts | 17 + modules/catalogs/src/api/module.ts | 38 + modules/companies/package.json | 3 +- modules/companies/src/api/index.ts | 79 +- .../src/api/infrastructure/di/companies.di.ts | 8 +- modules/companies/src/api/module.test.ts | 17 + modules/companies/src/api/module.ts | 22 + .../src/api/infrastructure/audit/index.ts | 1 + .../audit/sequelize/audit-change.model.ts | 12 + .../audit/sequelize/audit-event.model.ts | 24 + .../infrastructure/audit/sequelize/index.ts | 7 + .../audit/sequelize/sequelize-audit-store.ts | 67 + modules/core/src/api/infrastructure/index.ts | 1 + .../persistence/sequelize/database-config.ts | 14 + .../persistence/sequelize/index.ts | 2 + .../sequelize/sequelize-factory.ts | 47 + modules/core/src/api/modules/index.ts | 2 + .../core/src/api/modules/module-runtime.ts | 114 + .../core/src/api/modules/modules-runtime.ts | 25 + modules/customer-invoices/package.json | 4 +- .../src/api/application/index.ts | 1 + .../proformas/di/proforma-creator.di.ts | 7 +- .../services/catalog-resolver/index.ts | 1 + .../proforma-tax-config-resolver.test.ts | 51 + .../proforma-tax-config-resolver.ts | 101 + .../catalog-resolver/proforma-tax-resolver.ts | 13 +- .../proformas/services/proforma-creator.ts | 179 +- .../recurring-proformas/audit/index.ts | 1 + .../audit/recurring-proforma-audit.ts | 37 + .../application/recurring-proformas/index.ts | 4 + .../recurring-proformas/models/index.ts | 1 + .../models/recurring-proforma-read.models.ts | 100 + .../recurring-proformas/repositories/index.ts | 2 + ...roforma-occurrence-repository.interface.ts | 41 + ...recurring-proforma-repository.interface.ts | 47 + ...recurring-proforma-occurrence-processor.ts | 20 + .../recurring-proformas/services/index.ts | 8 + ...g-proforma-activation-readiness-checker.ts | 87 + .../recurring-proforma-capabilities.test.ts | 202 + .../recurring-proforma-capabilities.ts | 258 ++ ...ing-proforma-from-proforma-creator.test.ts | 156 + ...ecurring-proforma-from-proforma-creator.ts | 79 + .../recurring-proforma-materializer.test.ts | 27 + .../recurring-proforma-materializer.ts | 83 + ...ring-proforma-occurrence-processor.test.ts | 696 ++++ ...recurring-proforma-occurrence-processor.ts | 109 + .../recurring-proforma-services.test.ts | 140 + .../services/recurring-proforma-services.ts | 107 + .../services/recurring-proforma-updater.ts | 204 + .../customer-invoices/src/api/domain/index.ts | 1 + .../api/domain/recurring-proformas/index.ts | 5 + .../recurrence-schedule.test.ts | 120 + .../recurrence-schedule.ts | 198 + .../recurring-proforma-item.test.ts | 53 + .../recurring-proforma-item.ts | 77 + .../recurring-proforma-items.test.ts | 38 + .../recurring-proforma-items.ts | 17 + .../recurring-proforma-occurrence.test.ts | 186 + .../recurring-proforma-occurrence.ts | 382 ++ .../recurring-proforma.test.ts | 397 ++ .../recurring-proformas/recurring-proforma.ts | 766 ++++ modules/customer-invoices/src/api/index.ts | 2 + .../common/persistence/sequelize/index.ts | 6 + .../persistence/sequelize/models/index.ts | 3 + .../models/recurring-proforma-item.model.ts | 67 + .../models/recurring-proforma-models.test.ts | 31 + .../recurring-proforma-occurrence.model.ts | 78 + .../models/recurring-proforma.model.ts | 105 + .../src/api/infrastructure/index.ts | 1 + .../proformas/di/proformas.di.test.ts | 35 + .../proformas/di/proformas.di.ts | 132 + .../list-proformas.controller.test.ts | 192 + .../controllers/list-proformas.controller.ts | 11 +- .../proformas/express/proformas.routes.ts | 14 + .../repositories/proforma-v2.repository.ts | 2 +- ...-proforma-from-proforma.controller.test.ts | 178 + ...rring-proforma-from-proforma.controller.ts | 68 + ...eate-recurring-proforma.controller.test.ts | 12 + .../express/controllers/index.ts | 2 + ...ng-proforma-transitions.controller.test.ts | 220 + .../recurring-proformas-read.controllers.ts | 219 + ...date-recurring-proforma.controller.test.ts | 15 + .../express/mappers/parse-date-only.test.ts | 34 + .../express/mappers/parse-date-only.ts | 6 + .../express/presenters/index.ts | 84 + ...rring-proforma-readiness.presenter.test.ts | 14 + .../recurring-proforma-readiness.presenter.ts | 8 + .../recurring-proformas-read.http.test.ts | 151 + ...ecurring-proformas-read.presenters.test.ts | 33 + .../express/recurring-proformas.routes.ts | 122 + .../recurring-proformas/index.ts | 2 + .../recurring-proformas/persistence/index.ts | 1 + .../persistence/sequelize/index.ts | 2 + .../persistence/sequelize/mappers/index.ts | 2 + .../recurring-proforma-domain.mapper.test.ts | 135 + .../recurring-proforma-domain.mapper.ts | 278 ++ ...-proforma-occurrence-domain.mapper.test.ts | 27 + ...rring-proforma-occurrence-domain.mapper.ts | 75 + .../sequelize/repositories/index.ts | 2 + ...ing-proforma-occurrence.repository.test.ts | 177 + ...ecurring-proforma-occurrence.repository.ts | 188 + ...lize-recurring-proforma.repository.test.ts | 109 + ...sequelize-recurring-proforma.repository.ts | 249 ++ modules/customer-invoices/src/api/module.ts | 34 + .../src/common/dto/request/index.ts | 1 + ...proforma-from-proforma.request.dto.test.ts | 35 + ...ring-proforma-from-proforma.request.dto.ts | 20 + .../dto/request/recurring-proformas/index.ts | 2 + .../recurring-proformas-read.request.dto.ts | 24 + .../src/common/locales/en.json | 125 + .../src/common/locales/es.json | 125 + .../src/web/customer-invoice-routes.tsx | 36 + .../use-list-proformas.controller.ts | 38 +- .../list/types/proforma-list-filters.ts | 9 + .../web/proformas/list/ui/components/index.ts | 3 +- .../proforma-date-preset-filter.tsx | 76 + .../list/ui/pages/list-proformas-page.tsx | 101 +- .../build-list-proformas-criteria.test.ts | 81 + .../utils/build-list-proformas-criteria.ts | 37 +- .../utils/build-proforma-date-range.test.ts | 55 + .../list/utils/build-proforma-date-range.ts | 85 + .../resolve-proforma-date-preset.test.ts | 21 + .../utils/resolve-proforma-date-preset.ts | 24 + .../web/recurring-proformas/create/index.ts | 1 + .../create/recurring-proforma-create-page.tsx | 70 + .../recurring-proforma-create-route.test.tsx | 60 + .../recurring-proforma-create-utils.test.ts | 23 + .../create/recurring-proforma-create-utils.ts | 20 + .../web/recurring-proformas/detail/index.ts | 1 + .../detail/recurring-proforma-detail-page.tsx | 241 ++ .../recurring-proforma-detail-utils.test.ts | 59 + .../detail/recurring-proforma-detail-utils.ts | 51 + ...proforma-occurrences-section-utils.test.ts | 35 + ...ring-proforma-occurrences-section-utils.ts | 27 + ...recurring-proforma-occurrences-section.tsx | 116 + .../src/web/recurring-proformas/index.ts | 3 + ...ist-recurring-proformas-page-controller.ts | 202 + .../list-recurring-proformas-page.test.tsx | 394 ++ .../list/list-recurring-proformas-page.tsx | 185 + .../recurring-proforma-create-cta.test.tsx | 55 + .../recurring-proforma-list-utils.test.ts | 37 + .../list/recurring-proforma-list-utils.ts | 40 + ...ist-recurring-proformas-page-view.test.tsx | 119 + ...ped-list-recurring-proformas-page-view.tsx | 47 + .../api/create-recurring-proforma.api.test.ts | 24 + .../api/create-recurring-proforma.api.ts | 17 + .../api/get-recurring-proforma.api.test.ts | 30 + .../shared/api/get-recurring-proforma.api.ts | 19 + ...recurring-proforma-occurrences.api.test.ts | 28 + ...list-recurring-proforma-occurrences.api.ts | 22 + .../api/list-recurring-proformas.api.test.ts | 30 + .../api/list-recurring-proformas.api.ts | 13 + .../hooks/recurring-proforma-query-keys.ts | 24 + .../hooks/use-create-recurring-proforma.ts | 21 + .../use-recurring-proforma-occurrences.ts | 22 + .../shared/hooks/use-recurring-proforma.ts | 16 + .../shared/hooks/use-recurring-proformas.ts | 17 + .../shared/recurring-proforma-list.ts | 102 + .../recurring-proforma-navigation.test.ts | 11 + .../shared/recurring-proforma-navigation.ts | 2 + modules/customers/package.json | 3 +- modules/customers/src/api/index.ts | 80 +- modules/customers/src/api/module.test.ts | 17 + modules/customers/src/api/module.ts | 20 + modules/document-series/package.json | 5 +- modules/document-series/src/api/index.ts | 38 +- .../document-series/src/api/module.test.ts | 17 + modules/document-series/src/api/module.ts | 25 + modules/identity/package.json | 3 +- .../use-cases/refresh-session.use-case.ts | 154 +- .../create-identity-company-access.ts | 10 + .../company-memberships/services/index.ts | 1 + modules/identity/src/api/index.ts | 46 +- .../di/identity-auth-http-public-services.ts | 40 + .../di/identity-public-services.ts | 48 +- modules/identity/src/api/module.test.ts | 26 + modules/identity/src/api/module.ts | 22 + packages/rdx-audit/src/audit.ts | 31 +- packages/rdx-audit/src/audit.types.ts | 20 +- packages/rdx-criteria/src/codelytv/Filter.ts | 11 +- .../rdx-criteria/src/codelytv/filter.test.ts | 21 + .../criteria-to-sequelize-converter.test.ts | 38 + .../src/criteria-to-sequelize-converter.ts | 5 + packages/rdx-logger/src/configure-logger.ts | 7 +- .../src/strategies/console-logger.ts | 20 +- pnpm-lock.yaml | 61 + 221 files changed, 15842 insertions(+), 1528 deletions(-) create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/config/worker-config.ts create mode 100644 apps/worker/src/config/worker-env-schema.ts create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/src/jobs/recurring-proforma-scheduled-job.ts create mode 100644 apps/worker/src/modules/register-modules.ts create mode 100644 apps/worker/src/scheduler/register-jobs.ts create mode 100644 apps/worker/src/scheduler/scheduled-job.ts create mode 100644 apps/worker/src/scheduler/scheduler.test.ts create mode 100644 apps/worker/src/scheduler/scheduler.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 apps/worker/tsup.config.ts create mode 100644 docs/recurring-proformas/DOCUMENTO DE DISEÑO — recurring-proformas.md create mode 100644 modules/catalogs/src/api/module.test.ts create mode 100644 modules/catalogs/src/api/module.ts create mode 100644 modules/companies/src/api/module.test.ts create mode 100644 modules/companies/src/api/module.ts create mode 100644 modules/core/src/api/infrastructure/audit/index.ts create mode 100644 modules/core/src/api/infrastructure/audit/sequelize/audit-change.model.ts create mode 100644 modules/core/src/api/infrastructure/audit/sequelize/audit-event.model.ts create mode 100644 modules/core/src/api/infrastructure/audit/sequelize/index.ts create mode 100644 modules/core/src/api/infrastructure/audit/sequelize/sequelize-audit-store.ts create mode 100644 modules/core/src/api/infrastructure/persistence/sequelize/database-config.ts create mode 100644 modules/core/src/api/infrastructure/persistence/sequelize/sequelize-factory.ts create mode 100644 modules/core/src/api/modules/module-runtime.ts create mode 100644 modules/core/src/api/modules/modules-runtime.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.test.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/audit/index.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/audit/recurring-proforma-audit.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/index.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/models/index.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/repositories/index.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-occurrence-repository.interface.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-repository.interface.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/create-recurring-proforma-occurrence-processor.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/index.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-activation-readiness-checker.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.test.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.test.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.test.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.test.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.ts create mode 100644 modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-updater.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/index.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.test.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.test.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.test.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.test.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.test.ts create mode 100644 modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-item.model.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-models.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-occurrence.model.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma.model.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/index.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts create mode 100644 modules/customer-invoices/src/api/module.ts create mode 100644 modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.test.ts create mode 100644 modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/request/recurring-proformas/index.ts create mode 100644 modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/ui/components/proforma-date-preset-filter.tsx create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.test.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.test.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.test.ts create mode 100644 modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/create/index.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-route.test.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-utils.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-utils.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/index.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-page.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-utils.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-detail-utils.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-occurrences-section-utils.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-occurrences-section-utils.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/detail/recurring-proforma-occurrences-section.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/index.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/controllers/use-list-recurring-proformas-page-controller.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.test.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/list-recurring-proformas-page.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-create-cta.test.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/recurring-proforma-list-utils.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/ui/pages/scoped-list-recurring-proformas-page-view.test.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/list/ui/pages/scoped-list-recurring-proformas-page-view.tsx create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma.api.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/create-recurring-proforma.api.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/get-recurring-proforma.api.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/get-recurring-proforma.api.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proforma-occurrences.api.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proforma-occurrences.api.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/api/list-recurring-proformas.api.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/recurring-proforma-query-keys.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-create-recurring-proforma.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma-occurrences.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proforma.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/hooks/use-recurring-proformas.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-list.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-navigation.test.ts create mode 100644 modules/customer-invoices/src/web/recurring-proformas/shared/recurring-proforma-navigation.ts create mode 100644 modules/customers/src/api/module.test.ts create mode 100644 modules/customers/src/api/module.ts create mode 100644 modules/document-series/src/api/module.test.ts create mode 100644 modules/document-series/src/api/module.ts create mode 100644 modules/identity/src/api/application/company-memberships/services/create-identity-company-access.ts create mode 100644 modules/identity/src/api/infrastructure/di/identity-auth-http-public-services.ts create mode 100644 modules/identity/src/api/module.test.ts create mode 100644 modules/identity/src/api/module.ts create mode 100644 packages/rdx-criteria/src/codelytv/filter.test.ts create mode 100644 packages/rdx-criteria/src/criteria-to-sequelize-converter.test.ts diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts index 5192fd08..c419df85 100644 --- a/apps/server/src/config/config.ts +++ b/apps/server/src/config/config.ts @@ -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, diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts index 807b7288..15855587 100644 --- a/apps/server/src/config/database.ts +++ b/apps/server/src/config/database.ts @@ -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 { - 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 { - 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); } diff --git a/apps/server/src/config/env-schema.ts b/apps/server/src/config/env-schema.ts index d994f13d..4551ef2f 100644 --- a/apps/server/src/config/env-schema.ts +++ b/apps/server/src/config/env-schema.ts @@ -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), diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 683888ea..d297a84b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 | 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) => { 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, diff --git a/apps/server/src/infrastructure/audit/audit-reader.ts b/apps/server/src/infrastructure/audit/audit-reader.ts index a27aa15f..49f630fd 100644 --- a/apps/server/src/infrastructure/audit/audit-reader.ts +++ b/apps/server/src/infrastructure/audit/audit-reader.ts @@ -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" } | { diff --git a/apps/server/src/infrastructure/audit/sequelize/index.ts b/apps/server/src/infrastructure/audit/sequelize/index.ts index 1f71d76d..e8e4e6bf 100644 --- a/apps/server/src/infrastructure/audit/sequelize/index.ts +++ b/apps/server/src/infrastructure/audit/sequelize/index.ts @@ -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"; diff --git a/apps/server/src/infrastructure/audit/sequelize/sequelize-audit-store.ts b/apps/server/src/infrastructure/audit/sequelize/sequelize-audit-store.ts index 024a6dde..4d5a973f 100644 --- a/apps/server/src/infrastructure/audit/sequelize/sequelize-audit-store.ts +++ b/apps/server/src/infrastructure/audit/sequelize/sequelize-audit-store.ts @@ -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> { - const transaction = this.transactionContext.current(); + async append(entry: AuditEntry, options?: AuditRecordOptions): Promise> { + 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.") diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts index a9bd8464..2465ca42 100644 --- a/apps/server/src/lib/logger.ts +++ b/apps/server/src/lib/logger.ts @@ -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 }; diff --git a/apps/server/src/lib/modules/model-loader.ts b/apps/server/src/lib/modules/model-loader.ts index e2c11ec9..ba2c6acd 100644 --- a/apps/server/src/lib/modules/model-loader.ts +++ b/apps/server/src/lib/modules/model-loader.ts @@ -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; - models: Record; -}; - -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 = 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 => { - 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"; diff --git a/apps/server/src/lib/modules/module-loader.test.ts b/apps/server/src/lib/modules/module-loader.test.ts index a9c31d2f..c1c01fe2 100644 --- a/apps/server/src/lib/modules/module-loader.test.ts +++ b/apps/server/src/lib/modules/module-loader.test.ts @@ -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 ); }); diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index 7dac063e..49d5d008 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -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 = new Map(); - -// Nuevo: control por fases -const setupDone = new Set(); // para detección de ciclos -const visiting = new Set(); -const setupOrder: string[] = []; - -// Internal store -const internalByModule: Map> = new Map(); - -// Tracking de dependencias realmente usadas por módulo -const usedDependenciesByModule: Map> = 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 (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(moduleName, pkg.dependencies ?? [], serviceName); - }; -} - -/** getInternal: por defecto solo permite al propio módulo acceder a su internal */ -function makeGetInternal(requesterModule: string) { - return (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) { - 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(); - - // ❌ 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( - moduleName: string, - phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup", - fn: () => Promise | T -): Promise { - 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(p: Promise, ms: number, label: string): Promise { - let timer: NodeJS.Timeout | null = null; - const timeout = new Promise((_, 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; -} diff --git a/apps/server/src/lib/modules/service-registry.ts b/apps/server/src/lib/modules/service-registry.ts index 9e00249a..033035be 100644 --- a/apps/server/src/lib/modules/service-registry.ts +++ b/apps/server/src/lib/modules/service-registry.ts @@ -1,81 +1,6 @@ -const services: Record = {}; - -/** - * 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( - requesterModule: string, - allowedDeps: readonly string[], - name: string -): T { - const [serviceModule, ...key] = name.split(":"); - - if (serviceModule === "self") { - return getService(`${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(name); -} - -/** - * Recupera un servicio registrado, con tipado opcional. - */ -function getService(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"; diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 79b69cc9..00bb264e 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -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 diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts index 2f4a5eb2..ea07e6cc 100644 --- a/apps/server/src/testing/http/http-test-runtime.ts +++ b/apps/server/src/testing/http/http-test-runtime.ts @@ -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 { diff --git a/apps/web/src/layout/app-sidebar.config.ts b/apps/web/src/layout/app-sidebar.config.ts index ecc56a47..df7af602 100644 --- a/apps/web/src/layout/app-sidebar.config.ts +++ b/apps/web/src/layout/app-sidebar.config.ts @@ -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 }, ], diff --git a/apps/web/src/layout/app-topbar.tsx b/apps/web/src/layout/app-topbar.tsx index 4d80128c..41a7aa4f 100644 --- a/apps/web/src/layout/app-topbar.tsx +++ b/apps/web/src/layout/app-topbar.tsx @@ -88,6 +88,9 @@ export const AppTopbar = () => { navigate("/customers/create")}> {t("web.home.actions.newCustomer")} + navigate("/suppliers/create")}> + {t("web.home.actions.newSupplier")} + diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 387e2464..7ff0aae2 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -56,7 +56,8 @@ "actions": { "new": "New", "newProforma": "New proforma", - "newCustomer": "New customer" + "newCustomer": "New customer", + "newSupplier": "New supplier" }, "kpis": { "customers": { diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 5534a740..42d9ed72 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -56,7 +56,8 @@ "actions": { "new": "Nuevo", "newProforma": "Nueva proforma", - "newCustomer": "Nuevo cliente" + "newCustomer": "Nuevo cliente", + "newSupplier": "Nuevo proveedor" }, "kpis": { "customers": { diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 00000000..3e440acb --- /dev/null +++ b/apps/worker/package.json @@ -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" } +} diff --git a/apps/worker/src/config/worker-config.ts b/apps/worker/src/config/worker-config.ts new file mode 100644 index 00000000..80a50982 --- /dev/null +++ b/apps/worker/src/config/worker-config.ts @@ -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 } } }; +} diff --git a/apps/worker/src/config/worker-env-schema.ts b/apps/worker/src/config/worker-env-schema.ts new file mode 100644 index 00000000..84d2db04 --- /dev/null +++ b/apps/worker/src/config/worker-env-schema.ts @@ -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), +}); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts new file mode 100644 index 00000000..8ffbda74 --- /dev/null +++ b/apps/worker/src/index.ts @@ -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 | null = null; + +async function bootstrap(): Promise { + 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); }); diff --git a/apps/worker/src/jobs/recurring-proforma-scheduled-job.ts b/apps/worker/src/jobs/recurring-proforma-scheduled-job.ts new file mode 100644 index 00000000..2275040a --- /dev/null +++ b/apps/worker/src/jobs/recurring-proforma-scheduled-job.ts @@ -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 { + 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() }); + } +} diff --git a/apps/worker/src/modules/register-modules.ts b/apps/worker/src/modules/register-modules.ts new file mode 100644 index 00000000..0013f4d0 --- /dev/null +++ b/apps/worker/src/modules/register-modules.ts @@ -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); +} diff --git a/apps/worker/src/scheduler/register-jobs.ts b/apps/worker/src/scheduler/register-jobs.ts new file mode 100644 index 00000000..c192d86c --- /dev/null +++ b/apps/worker/src/scheduler/register-jobs.ts @@ -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())); + }, + })]; +} diff --git a/apps/worker/src/scheduler/scheduled-job.ts b/apps/worker/src/scheduler/scheduled-job.ts new file mode 100644 index 00000000..5e454d62 --- /dev/null +++ b/apps/worker/src/scheduler/scheduled-job.ts @@ -0,0 +1 @@ +export interface ScheduledJob { readonly name: string; readonly intervalMs: number; execute(): Promise; } diff --git a/apps/worker/src/scheduler/scheduler.test.ts b/apps/worker/src/scheduler/scheduler.test.ts new file mode 100644 index 00000000..75a097bd --- /dev/null +++ b/apps/worker/src/scheduler/scheduler.test.ts @@ -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((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; +}); diff --git a/apps/worker/src/scheduler/scheduler.ts b/apps/worker/src/scheduler/scheduler.ts new file mode 100644 index 00000000..a816722d --- /dev/null +++ b/apps/worker/src/scheduler/scheduler.ts @@ -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[] = []; + 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(); + 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 { + 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 { + 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 { + await new Promise((resolve) => { + let timeout: NodeJS.Timeout; + const cancel = () => { clearTimeout(timeout); this.delays.delete(cancel); resolve(); }; + timeout = setTimeout(cancel, intervalMs); + this.delays.add(cancel); + }); + } +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 00000000..fd27215f --- /dev/null +++ b/apps/worker/tsconfig.json @@ -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"] +} diff --git a/apps/worker/tsup.config.ts b/apps/worker/tsup.config.ts new file mode 100644 index 00000000..6e9bc749 --- /dev/null +++ b/apps/worker/tsup.config.ts @@ -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"] }); diff --git a/biome.json b/biome.json index a2478208..de39f2c4 100644 --- a/biome.json +++ b/biome.json @@ -33,7 +33,8 @@ "lineEnding": "lf", "attributePosition": "auto", "bracketSpacing": true, - "bracketSameLine": true + "bracketSameLine": true, + "expand": "auto" }, "linter": { "enabled": true, diff --git a/docs/recurring-proformas/DOCUMENTO DE DISEÑO — recurring-proformas.md b/docs/recurring-proformas/DOCUMENTO DE DISEÑO — recurring-proformas.md new file mode 100644 index 00000000..b163b6f5 --- /dev/null +++ b/docs/recurring-proformas/DOCUMENTO DE DISEÑO — recurring-proformas.md @@ -0,0 +1,3542 @@ +# DOCUMENTO DE DISEÑO — `recurring-proformas` + +## 1. Objetivo + +Diseñar la característica **Proformas recurrentes** del ERP. + +Una `RecurringProforma` representa una plantilla comercial programada que genera periódicamente nuevas `Proforma`. + +La característica NO genera directamente `IssuedInvoice`. + +Flujo obligatorio: + +```text +RecurringProforma + ↓ +RecurringProformaOccurrence + ↓ +Proforma + ↓ flujo normal existente +IssuedInvoice +``` + +La generación recurrente termina en la creación de una `Proforma`. + +A partir de ese momento, la Proforma sigue exactamente el mismo ciclo de vida que una Proforma creada manualmente. + +--- + +## 2. Alcance V1 + +V1 incluye: + +- creación de plantillas recurrentes; +- edición; +- activación explícita; +- pausa y reanudación; +- finalización; +- archivado; +- calendario recurrente; +- generación automática; +- generación manual anticipada de la próxima ocurrencia; +- historial de ocurrencias; +- retry de ocurrencias fallidas; +- recuperación de ejecuciones abandonadas; +- creación desde una Proforma existente; +- duplicación de una recurrencia; +- herencia dinámica de determinadas configuraciones; +- idempotencia; +- locking transaccional; +- auditoría; +- frontend completo de gestión. + +V1 NO incluye: + +- emisión automática de `IssuedInvoice`; +- envío automático a VeriFactu; +- `Subscription`; +- contratos; +- trials; +- renovaciones; +- facturación por consumo; +- prorrateos; +- cobro automático; +- generación extraordinaria fuera de calendario; +- `skip` de ocurrencias; +- expresiones cron configurables; +- calendarios laborales; +- festivos; +- reglas avanzadas de períodos; +- variables dinámicas en textos; +- productos o artículos; +- permisos específicos de recurrencias; +- timezone por recurrencia; +- historial completo de intentos de ejecución. + +--- + +## 3. Ubicación funcional + +`RecurringProforma` pertenece al módulo: + +```text +customer-invoices +``` + +como agregado independiente. + +Estructura conceptual: + +```text +customer-invoices +├─ proformas +├─ issued-invoices +└─ recurring-proformas +``` + +No se crea un módulo genérico: + +```text +recurring-billing +``` + +porque actualmente solo existe un caso funcional real: generar Proformas recurrentes. + +--- + +## 4. Principio fundamental + +`RecurringProforma` no es una Proforma futura parcialmente creada. + +Representa: + +```text +configuración comercial futura ++ +calendario ++ +estado de automatización +``` + +Cada ocurrencia genera una `Proforma` completamente independiente. + +Las Proformas previamente generadas nunca se modifican porque cambie posteriormente la `RecurringProforma`. + +--- + +## 5. Agregados y objetos principales + +El subdominio contiene: + +```text +RecurringProforma +RecurringProformaItem +RecurringProformaItems +RecurrenceSchedule +RecurringProformaOccurrence +``` + +### Aggregate Roots + +```text +RecurringProforma +RecurringProformaOccurrence +``` + +`RecurringProformaOccurrence` es Aggregate Root independiente. + +No se carga como colección dentro de `RecurringProforma`. + +Motivos: + +- tiene identidad propia; +- tiene estados propios; +- tiene lifecycle propio; +- tiene retry; +- puede crecer indefinidamente; +- necesita consultas y paginación independientes; +- participa directamente en idempotencia y recuperación. + +--- + +# 6. `RecurringProforma` + +## 6.1 Identidad + +Toda `RecurringProforma` tiene: + +```text +id: UniqueID +``` + +`UniqueID` contiene un UUID, siguiendo la convención general de entidades del ERP. + +El UUID es la identidad única técnica. + +No existe numeración documental tipo: + +```text +RP-0001 +``` + +en V1. + +--- + +## 6.2 Campos internos de identificación + +```text +name +description +``` + +### `name` + +Obligatorio. + +Reglas: + +```text +trimmed +no vacío +``` + +No es único. + +Se permiten nombres duplicados dentro de una misma compañía. + +### `description` + +Opcional. + +Uso exclusivamente interno. + +No se copia a las Proformas generadas. + +Debe diferenciarse de cualquier descripción comercial existente en `Proforma`. + +--- + +## 6.3 Tenant + +```text +companyId: UniqueID +``` + +Toda `RecurringProforma` pertenece a una compañía. + +Es company-scoped. + +--- + +## 6.4 Customer + +```text +customerId?: UniqueID +``` + +En `draft` puede no existir. + +En: + +```text +active +paused +``` + +es obligatorio. + +--- + +# 7. Estados de `RecurringProforma` + +Estados V1: + +```text +draft +active +paused +completed +archived +``` + +## `draft` + +Representa una plantilla en preparación. + +Puede estar incompleta. + +No participa en el scheduler. + +No genera occurrences. + +Puede editarse libremente dentro de las invariantes mínimas del draft. + +--- + +## `active` + +Participa en el scheduler. + +Debe encontrarse permanentemente en un estado válido para generar Proformas. + +--- + +## `paused` + +Suspende completamente la generación. + +No permite: + +- generación automática; +- `Generate next occurrence`. + +El calendario no se modifica durante la pausa. + +--- + +## `completed` + +Recurrencia finalizada. + +No genera más occurrences. + +Es inmutable. + +--- + +## `archived` + +Estado histórico final. + +Solo accesible desde `completed`. + +Es inmutable. + +No puede restaurarse en V1. + +Para reutilizarla debe duplicarse. + +--- + +# 8. Transiciones de estado + +Permitidas: + +```text +draft → active +draft → completed + +active → paused +active → completed + +paused → active +paused → completed + +completed → archived +``` + +Prohibidas: + +```text +completed → active +completed → paused +completed → draft + +archived → * + +draft → archived +active → archived +paused → archived +``` + +`completed` y `archived` son inmutables por `PATCH`. + +--- + +# 9. Draft incompleto + +`RecurringProforma.create()` debe aplicar solamente invariantes mínimas. + +Un draft puede crearse únicamente con: + +```text +name +``` + +Pueden faltar: + +```text +customer +items +schedule +campos comerciales +paymentTerm +paymentMethod +language +currency +series +``` + +La barrera fuerte está en: + +```text +draft → active +``` + +mediante activation readiness. + +--- + +# 10. Contenido comercial + +`RecurringProforma` debe replicar los campos comerciales no fiscales actualmente necesarios para crear una `Proforma`. + +Antes de implementación debe auditarse el agregado y persistencia real de `Proforma`. + +Solo se replicarán campos existentes y necesarios. + +No se inventará un modelo comercial paralelo. + +Se incluyen conceptualmente: + +- descripción comercial; +- notas; +- observaciones; +- términos; +- textos comerciales; +- descuentos globales; +- líneas; +- condiciones de pago; +- forma de pago; +- idioma; +- moneda; +- series documentales; +- cualquier otro campo comercial no fiscal realmente usado por `Proforma`. + +No se replican: + +- impuestos materializados; +- tax breakdown; +- totales derivados; +- estado documental; +- referencia documental; +- fechas de Proforma; +- datos de emisión; +- VeriFactu. + +--- + +# 11. `RecurringProformaItem` + +Entidad independiente de `ProformaItem`. + +No se reutiliza directamente `ProformaItem`. + +Campos V1: + +```text +id: UniqueID +description +quantity +unitPrice +discount +``` + +`discount` utiliza exactamente el mismo modelo funcional de descuento que el `ProformaItem` actual. + +No contiene: + +```text +taxes +taxDefinitionId +productId +articleId +``` + +Actualmente el ERP no dispone de productos ni artículos. + +--- + +# 12. `RecurringProformaItems` + +Colección basada en: + +```text +Collection +``` + +Actualización: + +```text +items omitido +→ no modificar + +items enviado +→ reemplazo completo +``` + +No se introduce actualización granular: + +```text +create/update/delete +``` + +por línea en V1. + +--- + +# 13. Items vacíos + +En `draft`: + +```text +items = [] +``` + +es válido. + +En: + +```text +active +paused +``` + +no se puede guardar una colección vacía. + +Activation readiness bloquea un draft vacío mediante: + +```text +missing_items +``` + +--- + +# 14. Fiscalidad + +`RecurringProforma` no almacena fiscalidad materializada. + +Cada ocurrencia debe resolver nuevamente la fiscalidad vigente. + +Flujo: + +```text +RecurringProforma + ↓ +Customer actual + ↓ +contexto fiscal actual + ↓ +creación normal de Proforma + ↓ +impuestos + ↓ +totales +``` + +La recurrencia no implementa: + +```text +RecurringProformaTaxCalculator +RecurringProformaTotalsCalculator +``` + +La lógica fiscal debe reutilizar la misma lógica utilizada por una Proforma creada manualmente. + +Si la situación fiscal vigente impide construir una Proforma válida: + +```text +Occurrence → failed +``` + +No se crea una Proforma parcial. + +--- + +# 15. Recipient + +En cada ocurrencia se obtiene nuevamente el Customer actual. + +El recipient snapshot de la nueva Proforma debe construirse usando los datos actuales del Customer. + +Cambios posteriores del Customer nunca modifican Proformas previamente generadas. + +--- + +# 16. Cambio de Customer + +Permitido en: + +```text +draft +active +paused +``` + +No permitido en: + +```text +completed +archived +``` + +En `active` y `paused`: + +- el cambio afecta solo a futuras occurrences; +- las Proformas existentes permanecen intactas; +- se aplican las mismas reglas de compatibilidad que en cambio de Customer de Proforma; +- idioma o moneda explícitos se respetan; +- incompatibilidades reales bloquean el cambio; +- la fiscalidad futura se recalcula; +- el recipient futuro se obtiene del nuevo Customer. + +`active` y `paused` nunca pueden quedar con: + +```text +customerId = null +``` + +--- + +# 17. Customer inactivo + +Si el Customer pasa a `inactive`: + +```text +Occurrence → failed +``` + +La recurrencia no pasa automáticamente a `paused`. + +La occurrence fallida bloquea las siguientes. + +Opciones de resolución: + +```text +reactivar Customer +→ retry +``` + +o: + +```text +cambiar Customer +→ retry +``` + +--- + +# 18. Campos heredables + +Los siguientes campos son opcionales: + +```text +paymentTermId +paymentMethodId +language +currency +proformaSeriesId +issuedInvoiceSeriesId +``` + +Semántica: + +```text +null +→ herencia dinámica + +valor explícito +→ override autoritativo +``` + +Los valores heredados se resuelven en cada occurrence. + +Nunca se escriben de vuelta en `RecurringProforma`. + +Ejemplo: + +```text +RecurringProforma.currency = null +Customer.currency = EUR + +Occurrence +→ Proforma.currency = EUR + +RecurringProforma.currency continúa = null +``` + +--- + +# 19. Overrides explícitos + +Si el usuario selecciona manualmente un valor, aunque coincida con el valor heredado actual: + +```text +→ pasa a ser override +``` + +Si selecciona nuevamente: + +```text +Heredar +``` + +se persiste: + +```text +null +``` + +--- + +# 20. PaymentMethod y PaymentTerm + +Si existe override explícito y el recurso pasa a estar: + +```text +inactive +unavailable +``` + +la occurrence falla. + +No existe fallback automático. + +El usuario debe: + +```text +reactivar +``` + +o: + +```text +cambiar el override +``` + +y posteriormente: + +```text +retry +``` + +Si el valor es heredado, se utiliza la resolución normal vigente de creación de Proforma. + +--- + +# 21. Idioma y moneda + +También pueden heredarse dinámicamente. + +Override explícito: + +```text +→ autoritativo +``` + +Valor heredado: + +```text +→ se resuelve nuevamente en cada occurrence +``` + +Cualquier incompatibilidad que impida generar una Proforma válida provoca: + +```text +Occurrence → failed +``` + +--- + +# 22. Series documentales + +`RecurringProforma` puede guardar opcionalmente: + +```text +proformaSeriesId?: UniqueID +issuedInvoiceSeriesId?: UniqueID +``` + +La semántica debe replicar la de `Proforma`. + +### Serie explícita + +Si se configura explícitamente: + +```text +→ debe utilizarse +``` + +Si deja de estar: + +```text +activa +vigente +disponible +``` + +la occurrence falla. + +No existe fallback silencioso. + +### Serie heredada + +Si es `null`: + +```text +→ usar resolución/default vigente +``` + +### Issued invoice series + +`issuedInvoiceSeriesId` solamente se copia como preferencia/configuración a la Proforma creada. + +No implica emisión automática ni reserva de numeración. + +--- + +# 23. Regla general explícito/heredado + +```text +valor explícito +→ autoritativo +→ si deja de ser válido, failed + +null +→ resolver dinámicamente +→ no persistir resultado en RecurringProforma +``` + +--- + +# 24. `RecurrenceSchedule` + +Value Object inmutable. + +Campos: + +```text +startDate +endDate? +unit +interval +monthlyRule? +``` + +`unit`: + +```text +week +month +year +``` + +`interval`: + +```text +integer > 0 +``` + +`monthlyRule`: + +```text +same_day +last_day_of_month +``` + +--- + +# 25. Invariantes de `RecurrenceSchedule` + +```text +startDate obligatorio + +interval > 0 + +endDate == null +OR +endDate >= startDate +``` + +Para: + +```text +unit = month +``` + +`monthlyRule` es obligatorio. + +Para: + +```text +unit = week +unit = year +``` + +`monthlyRule` debe ser `null`. + +--- + +# 26. `startDate` + +`startDate` representa siempre: + +```text +la primera occurrence ++ +el ancla del calendario +``` + +No existe: + +```text +firstOccurrenceDate +``` + +separada. + +--- + +# 27. Recurrencia semanal + +Ejemplo: + +```text +startDate = 07/09/2026 +unit = week +interval = 2 +``` + +Resultado: + +```text +07/09 +21/09 +05/10 +... +``` + +El día de la semana deriva de `startDate`. + +--- + +# 28. Recurrencia mensual `same_day` + +Ejemplo: + +```text +startDate = 31/01/2027 +unit = month +interval = 1 +monthlyRule = same_day +``` + +Resultado: + +```text +31/01 +28/02 +31/03 +30/04 +31/05 +``` + +Si el día ancla no existe se usa el último día válido del mes. + +El ancla original nunca se pierde. + +--- + +# 29. Recurrencia mensual `last_day_of_month` + +`startDate` sigue siendo siempre la primera occurrence exacta. + +Ejemplo: + +```text +startDate = 15/01/2027 +interval = 1 +monthlyRule = last_day_of_month +``` + +Resultado: + +```text +15/01 +28/02 +31/03 +30/04 +31/05 +``` + +Con: + +```text +interval = 3 +``` + +resultado: + +```text +15/01 +30/04 +31/07 +31/10 +31/01 +... +``` + +--- + +# 30. Recurrencia anual + +El ancla se conserva desde `startDate`. + +Caso: + +```text +startDate = 29/02/2028 +unit = year +interval = 1 +``` + +Resultado: + +```text +29/02/2028 +28/02/2029 +28/02/2030 +28/02/2031 +29/02/2032 +``` + +Con `interval > 1` se aplica la misma regla sobre los años objetivo. + +--- + +# 31. Cálculo del calendario + +No calcular: + +```text +next = previous occurrence + interval +``` + +si eso puede perder el ancla. + +El cálculo debe derivarse siempre del calendario original basado en: + +```text +startDate ++ +índice lógico de occurrence +``` + +o una implementación matemáticamente equivalente que conserve el ancla. + +--- + +# 32. `endDate` + +Opcional. + +Si es `null`: + +```text +recurrencia indefinida +``` + +Es inclusiva: + +```text +scheduledDate <= endDate +→ válida +``` + +`endDate` no genera por sí misma una occurrence. + +Solo limita el calendario. + +Ejemplo: + +```text +startDate = 01/01 +interval = 1 month +endDate = 15/03 +``` + +Genera: + +```text +01/01 +01/02 +01/03 +``` + +No genera: + +```text +15/03 +``` + +--- + +# 33. Modificación de `endDate` + +Puede: + +- añadirse; +- adelantarse; +- retrasarse. + +No puede quedar antes de una `scheduledDate` ya materializada como `generated`. + +Si al adelantarla ya no quedan occurrences futuras y no existen incidencias: + +```text +→ completed +``` + +--- + +# 34. Finalización automática + +Al generar la última occurrence válida: + +```text +next calculated date > endDate +``` + +entonces: + +```text +nextOccurrenceDate = null +status = completed +``` + +si no existen incidencias pendientes. + +--- + +# 35. EndDate pasada durante activación + +Es válido activar una recurrencia cuyo rango completo esté en el pasado. + +Si existen occurrences válidas dentro del rango: + +```text +→ se procesan todas +``` + +Tras resolverlas correctamente: + +```text +→ completed +``` + +Si una falla: + +```text +→ permanece active +``` + +hasta resolverla. + +--- + +# 36. `nextOccurrenceDate` + +Estado interno derivado y persistido en `RecurringProforma`. + +No pertenece a `RecurrenceSchedule`. + +No es editable desde API ni frontend. + +Semántica: + +```text +draft +→ null + +active +→ próxima occurrence lógica + +paused +→ conserva el mismo valor + +failed occurrence +→ continúa apuntando a esa scheduledDate + +completed +→ null + +archived +→ null +``` + +Al activar: + +```text +nextOccurrenceDate = startDate +``` + +--- + +# 37. Hora y timezone + +Las recurrencias trabajan con: + +```text +DATE +``` + +no con hora configurable. + +No existe timezone por `RecurringProforma`. + +El scheduler determina `today` usando: + +1. timezone de la compañía, si existe en el ERP; +2. en su defecto, política global del sistema. + +Si cambia la timezone: + +- no se modifican fechas del calendario; +- no se recalcula `nextOccurrenceDate`; +- solo cambia la evaluación de cuál es el día actual operativo. + +--- + +# 38. Scheduler + +La frecuencia exacta del scheduler es configuración de infraestructura. + +No pertenece al dominio. + +Debe ejecutarse periódicamente y procesar: + +```text +status = active +AND nextOccurrenceDate <= today +``` + +Debe recuperar occurrences atrasadas. + +No debe depender exclusivamente de: + +```text +nextOccurrenceDate == today +``` + +--- + +# 39. Ocurrencias atrasadas + +Si existen varias fechas vencidas: + +```text +→ procesar secuencialmente +→ en orden cronológico +``` + +Ejemplo: + +```text +enero +febrero +marzo +abril +``` + +Si marzo falla: + +```text +enero → generated +febrero → generated +marzo → failed +abril → no procesada +``` + +--- + +# 40. Pausa + +`pause` suspende la ejecución. + +No modifica el calendario. + +Ejemplo: + +```text +nextOccurrenceDate = 01/10 + +pause +resume en diciembre +``` + +Después del resume: + +```text +01/10 +01/11 +01/12 +``` + +se procesan en orden. + +Pausar no equivale a hacer `skip`. + +--- + +# 41. `RecurringProformaOccurrence` + +Aggregate Root independiente. + +Campos V1: + +```text +id: UniqueID +companyId: UniqueID +recurringProformaId: UniqueID + +scheduledDate + +status + +attemptCount + +lastAttemptAt? +processingStartedAt? + +lastErrorCode? +lastErrorMessage? + +proformaId? +generatedAt? + +createdAt +updatedAt +``` + +No contiene snapshots comerciales ni fiscales. + +--- + +# 42. Estados de occurrence + +```text +pending +processing +failed +generated +``` + +### `pending` + +Occurrence identificada pero no procesada. + +### `processing` + +Ejecución en curso. + +### `failed` + +Último intento falló. + +Bloquea occurrences posteriores. + +### `generated` + +Proforma creada correctamente. + +Estado terminal. + +--- + +# 43. Transiciones de occurrence + +Permitidas: + +```text +pending → processing + +processing → generated +processing → failed + +failed → processing +``` + +`generated` no permite ninguna transición posterior. + +--- + +# 44. Identidad lógica de occurrence + +Debe existir: + +```text +UNIQUE(recurring_proforma_id, scheduled_date) +``` + +Esto garantiza que una fecha lógica de una recurrencia solo tenga una occurrence. + +--- + +# 45. Relación con Proforma + +`RecurringProformaOccurrence` guarda: + +```text +proformaId?: UniqueID +``` + +Debe existir: + +```text +UNIQUE(proforma_id) +``` + +Una Proforma no puede pertenecer a dos occurrences. + +La Proforma NO contiene: + +```text +recurringProformaId +recurringOccurrenceId +``` + +La dirección de la relación es exclusivamente: + +```text +Occurrence → Proforma +``` + +--- + +# 46. Proforma vinculada + +En V1 una Proforma vinculada a una occurrence no puede eliminarse físicamente. + +La FK debe proteger la trazabilidad. + +No usar: + +```text +ON DELETE SET NULL +ON DELETE CASCADE +``` + +sobre `proforma_id`. + +--- + +# 47. Ciclo posterior de la Proforma + +Una vez: + +```text +Occurrence.status = generated +``` + +la occurrence no sigue el lifecycle de la Proforma. + +La Proforma puede posteriormente: + +- editarse; +- cambiar de estado; +- archivarse; +- emitirse. + +La occurrence continúa: + +```text +generated +``` + +--- + +# 48. IssuedInvoice en historial + +`RecurringProformaOccurrence` NO guarda: + +```text +issuedInvoiceId +``` + +El historial puede resolver como dato de lectura si la Proforma fue posteriormente emitida y qué `IssuedInvoice` resultó. + +Es información derivada, no persistida en occurrence. + +--- + +# 49. Creación lazy de occurrences + +No se pre-generan occurrences futuras. + +Flujo: + +```text +RecurringProforma.nextOccurrenceDate + ↓ +scheduler / generate next + ↓ +crear o recuperar occurrence + ↓ +pending + ↓ +processing +``` + +--- + +# 50. `attemptCount` + +Cada intento real incrementa: + +```text +attemptCount += 1 +``` + +y actualiza: + +```text +lastAttemptAt +``` + +Incluye: + +- primer intento; +- retry manual; +- recuperación de `processing` abandonado. + +--- + +# 51. Fallos + +Al fallar: + +```text +status = failed +lastErrorCode = ... +lastErrorMessage = ... +``` + +No se guarda historial completo de intentos. + +No existe: + +```text +RecurringProformaOccurrenceAttempt +``` + +en V1. + +Al generar finalmente correctamente: + +```text +status = generated +lastErrorCode = null +lastErrorMessage = null +``` + +La auditoría conserva los eventos funcionales anteriores. + +--- + +# 52. Fallo secuencial + +Una occurrence `failed`: + +- no pausa formalmente la `RecurringProforma`; +- bloquea occurrences posteriores; +- bloquea `complete`; +- requiere `retry`. + +No existe `skip` en V1. + +--- + +# 53. Retry + +Solo aplicable a: + +```text +status = failed +``` + +Utiliza la configuración actual de `RecurringProforma`. + +No utiliza un snapshot de la configuración que existía durante el intento anterior. + +Ejemplo: + +```text +PaymentMethod A inactive +→ failed + +usuario cambia a PaymentMethod B + +retry +→ usa B +``` + +Se reutiliza la misma occurrence. + +Nunca se crea otra para la misma `scheduledDate`. + +--- + +# 54. `processing` abandonado + +Una occurrence puede quedar en `processing` si el proceso cae. + +Se utiliza: + +```text +processingStartedAt +``` + +Si supera un threshold configurado en infraestructura: + +```text +→ puede recuperarse +``` + +Debe reutilizarse la misma occurrence. + +No introducir en dominio: + +```text +workerId +heartbeat +leaseOwner +``` + +salvo necesidad técnica futura. + +--- + +# 55. Generación manual + +Existe en V1: + +```text +Generate next occurrence +``` + +Solo disponible en: + +```text +active +``` + +No disponible en: + +```text +draft +paused +completed +archived +``` + +Procesa anticipadamente: + +```text +nextOccurrenceDate +``` + +No crea una occurrence extraordinaria. + +--- + +# 56. Generación anticipada + +Ejemplo: + +```text +nextOccurrenceDate = 01/10 +usuario genera el 25/09 +``` + +Resultado: + +```text +Occurrence.scheduledDate = 01/10 +Proforma.proformaDate = 25/09 +Proforma.operationDate = 25/09 +``` + +La siguiente fecha del calendario se calcula desde: + +```text +scheduledDate / calendario original +``` + +no desde la fecha efectiva de generación. + +--- + +# 57. Fechas de Proforma + +En V1: + +```text +proformaDate = generationDate +operationDate = generationDate +``` + +Después de generarse, el usuario puede modificar `operationDate` mediante el flujo normal de edición de Proforma. + +No se implementan reglas como: + +```text +último día del mes anterior +inicio del período +offset de N días +``` + +--- + +# 58. Generación anticipada fallida + +Se comporta exactamente igual que una occurrence automática fallida: + +```text +status = failed +nextOccurrenceDate no avanza +bloquea posteriores +requiere retry +``` + +--- + +# 59. Activación readiness + +La transición: + +```text +draft → active +``` + +requiere validación previa. + +Endpoint: + +```text +GET /recurring-proformas/:recurring_proforma_id/activation-readiness +``` + +Contrato conceptual: + +```json +{ + "can_activate": false, + "blockers": [ + { + "code": "missing_items" + } + ] +} +``` + +--- + +# 60. Activation blockers + +Candidatos iniciales: + +```text +missing_customer +customer_inactive +missing_items +missing_description +missing_schedule +invalid_schedule + +missing_payment_method +payment_method_unavailable +payment_method_inactive + +missing_payment_term +payment_term_unavailable +payment_term_inactive + +invalid_currency +invalid_language + +proforma_series_unavailable +issued_invoice_series_unavailable + +invalid_fiscal_configuration +``` + +Los nombres definitivos deberán reutilizar los errores y códigos existentes cuando sea posible. + +No duplicar semánticas ya existentes. + +--- + +# 61. Readiness vs ejecución + +Readiness solamente valida: + +```text +¿puede activarse ahora? +``` + +Cada occurrence vuelve a resolver y validar los datos vivos. + +Ejemplo: + +```text +activation readiness = OK +↓ +PaymentMethod se desactiva +↓ +siguiente occurrence = failed +``` + +--- + +# 62. Edición por estado + +## `draft` + +Editable: + +- name; +- description; +- Customer; +- items; +- campos comerciales; +- overrides; +- calendario; +- endDate. + +Puede quedar incompleto. + +## `active` / `paused` + +Editable: + +- name; +- description; +- Customer; +- items; +- campos comerciales; +- overrides; +- endDate. + +Todo PATCH debe dejar la recurrencia en un estado generable. + +No puede eliminar: + +- Customer; +- todas las líneas; +- schedule. + +## `completed` / `archived` + +Completamente inmutables. + +--- + +# 63. Edición del calendario + +Mientras no exista ninguna occurrence: + +```text +startDate +unit +interval +monthlyRule +``` + +pueden modificarse. + +Una vez exista historial: + +```text +startDate +unit +interval +monthlyRule +``` + +quedan bloqueados. + +`endDate` sigue siendo modificable según sus reglas propias. + +Para cambiar la cadencia se debe: + +```text +complete +→ duplicate +→ configurar nuevo calendario +``` + +--- + +# 64. Estado resultante válido en active/paused + +Cualquier `PATCH` sobre: + +```text +active +paused +``` + +debe validar el estado completo resultante. + +Ejemplo: + +```text +paymentMethodId: null +``` + +solo se permite si la herencia resultante resuelve un método válido. + +Misma regla para: + +```text +paymentTermId +language +currency +proformaSeriesId +issuedInvoiceSeriesId +``` + +--- + +# 65. Finalización manual + +Permitida desde: + +```text +draft +active +paused +``` + +Una `draft` puede pasar directamente a `completed`. + +Esto permite conservar una plantilla descartada sin borrarla. + +No puede completarse mientras exista una occurrence: + +```text +failed +``` + +--- + +# 66. Archivado + +Solo: + +```text +completed → archived +``` + +No restaurable en V1. + +--- + +# 67. Borrado físico + +Permitido únicamente cuando: + +```text +status = draft +AND no existen occurrences +``` + +En cualquier otro caso: + +```text +DELETE rechazado +``` + +--- + +# 68. Duplicación + +Endpoint: + +```text +POST /recurring-proformas/:recurring_proforma_id/duplicate +``` + +Resultado: + +```text +nuevo UniqueID +status = draft +nextOccurrenceDate = null +sin occurrences +sin historial +``` + +Se copia configuración comercial y schedule. + +No se copia estado operativo. + +El nombre puede mantenerse igual porque no existe unicidad. + +--- + +# 69. Crear desde Proforma + +Endpoint: + +```text +POST /recurring-proformas/from-proforma/:proforma_id +``` + +Crea: + +```text +RecurringProforma draft +``` + +Copia: + +- Customer; +- campos comerciales; +- items; +- descuentos; +- condiciones actuales; +- payment term; +- payment method; +- idioma; +- moneda; +- series. + +No copia: + +- ID; +- referencia; +- status; +- proformaDate; +- operationDate; +- impuestos; +- totales; +- issued invoice; +- VeriFactu. + +--- + +# 70. Procedencia al crear desde Proforma + +Si `Proforma` permite conocer si un campo era: + +```text +inherited +explicit +``` + +debe conservarse esa procedencia. + +Si no puede demostrarse: + +```text +→ copiar el valor actual como override explícito +``` + +Nunca inferir una herencia que el modelo actual no pueda demostrar. + +--- + +# 71. Persistencia + +Tablas V1: + +```text +recurring_proformas +recurring_proforma_items +recurring_proforma_occurrences +``` + +No se crean: + +```text +recurring_proforma_attempts +recurring_proforma_history +recurrence_schedules +``` + +--- + +# 72. Tabla `recurring_proformas` + +Campos conceptuales: + +```text +id +company_id + +name +description +status + +customer_id + +campos comerciales no fiscales + +payment_term_id +payment_method_id +language_code +currency_code + +proforma_series_id +issued_invoice_series_id + +start_date +end_date +recurrence_unit +recurrence_interval +monthly_rule + +next_occurrence_date + +created_at +updated_at +``` + +Los campos comerciales definitivos deben salir de auditoría del modelo actual de `Proforma`. + +--- + +# 73. Índices `recurring_proformas` + +Inicialmente: + +```text +INDEX (company_id, status, next_occurrence_date) +INDEX (company_id, customer_id) +``` + +No indexar `name` en V1 salvo evidencia real de necesidad. + +--- + +# 74. Tabla `recurring_proforma_items` + +Campos conceptuales: + +```text +id +recurring_proforma_id + +position + +description + +quantity_* +unit_price_* +discount_* +``` + +Sin: + +```text +company_id +created_at +updated_at +``` + +`companyId` se deriva desde `recurring_proforma_id`, igual que en `proforma_items`. + +--- + +# 75. Items FK + +```text +recurring_proforma_id +→ recurring_proformas.id +ON DELETE CASCADE +``` + +Las líneas forman parte interna del agregado. + +--- + +# 76. Orden de items + +Debe conservarse orden comercial. + +Usar: + +```text +position +``` + +si ese es el patrón necesario tras auditar `ProformaItem`. + +Recomendado: + +```text +UNIQUE(recurring_proforma_id, position) +``` + +--- + +# 77. Tabla `recurring_proforma_occurrences` + +Campos: + +```text +id +company_id +recurring_proforma_id + +scheduled_date +status + +attempt_count + +last_attempt_at +processing_started_at + +last_error_code +last_error_message + +proforma_id +generated_at + +created_at +updated_at +``` + +--- + +# 78. `company_id` en occurrences + +`RecurringProformaOccurrence` sí almacena explícitamente: + +```text +company_id +``` + +Motivo: + +- consultas tenant-scoped frecuentes; +- scheduler; +- incidencias; +- recuperación de processing; +- evitar JOINs o consultas previas. + +Debe coincidir siempre con: + +```text +RecurringProforma.companyId +``` + +No se utiliza FK compuesta. + +La coherencia se garantiza explícitamente en dominio/repositorio. + +--- + +# 79. Constraints occurrences + +Obligatorios: + +```text +UNIQUE(recurring_proforma_id, scheduled_date) +UNIQUE(proforma_id) +``` + +FKs: + +```text +recurring_proforma_id +→ recurring_proformas.id +ON DELETE RESTRICT +``` + +```text +proforma_id +→ proformas.id +ON DELETE RESTRICT +``` + +--- + +# 80. Índices occurrences + +Inicialmente: + +```text +INDEX (company_id, status) +INDEX (company_id, status, processing_started_at) + +UNIQUE (recurring_proforma_id, scheduled_date) +UNIQUE (proforma_id) +``` + +No añadir índices adicionales sin necesidad demostrada. + +--- + +# 81. `hasIssue` + +No se persiste. + +Se deriva: + +```text +exists occurrence +where status = failed +``` + +Puede exponerse en list/detail: + +```text +has_issue: boolean +``` + +--- + +# 82. Datos derivados no persistidos inicialmente + +No guardar: + +```text +last_occurrence_date +last_generated_at +occurrence_count +has_issue +``` + +Se materializarán únicamente si en el futuro existe evidencia de problemas de rendimiento. + +--- + +# 83. Repositorios + +Contratos separados: + +```text +IRecurringProformaRepository +IRecurringProformaOccurrenceRepository +``` + +No usar repositorio genérico. + +--- + +# 84. Finders + +Separar consultas especializadas: + +```text +RecurringProformaFinder +RecurringProformaOccurrenceFinder +RecurringProformaExecutionFinder +``` + +Sin CQRS formal. + +`ExecutionFinder` puede resolver: + +- recurrencias vencidas; +- incidencias; +- stale processing; +- consultas operativas del scheduler. + +--- + +# 85. Application services + +Servicios candidatos V1: + +```text +RecurringProformaCreator +RecurringProformaUpdater +RecurringProformaDeleter +RecurringProformaStatusChanger +RecurringProformaReadinessChecker +RecurringProformaDuplicator +RecurringProformaFromProformaCreator + +RecurringProformaMaterializer + +RecurringProformaOccurrenceProcessor +RecurringProformaOccurrenceRetryService +RecurringProformaSchedulerProcessor +``` + +Evitar: + +```text +RecurringProformaManager +RecurringBillingEngine +GenericRecurringService +GenericSchedulerService +``` + +--- + +# 86. `RecurringProformaMaterializer` + +Responsabilidad: + +```text +RecurringProforma actual ++ +Customer actual ++ +defaults actuales ++ +catálogos actuales ++ +contexto fiscal actual +↓ +entrada válida para crear Proforma +``` + +No persiste. + +No implementa lógica fiscal propia. + +--- + +# 87. Reutilización de `ProformaCreator` + +La generación recurrente debe acabar utilizando el mismo servicio responsable de crear Proformas normales. + +No debe existir una segunda implementación de creación de Proforma específica para recurrencias. + +La recurrencia materializa la entrada. + +`ProformaCreator` aplica: + +- invariantes; +- creación; +- fiscalidad; +- cálculos; +- persistencia mediante el flujo habitual. + +--- + +# 88. Transacción de generación + +La generación exitosa debe ser atómica. + +Conceptualmente: + +```text +BEGIN + +lock RecurringProforma + +crear/recuperar occurrence +mark processing + +resolver configuración vigente + +crear Proforma +persistir Proforma + +occurrence.markGenerated(proformaId) + +avanzar nextOccurrenceDate +o completar RecurringProforma + +persistir RecurringProforma +persistir occurrence + +auditoría + +COMMIT +``` + +Si cualquier paso falla: + +```text +ROLLBACK +``` + +No debe quedar: + +- Proforma huérfana; +- occurrence generated parcial; +- `nextOccurrenceDate` avanzado incorrectamente. + +--- + +# 89. Persistencia del fallo + +Tras rollback de la generación, el estado: + +```text +failed +``` + +se registra en una transacción separada. + +Debe conservar: + +```text +attemptCount +lastAttemptAt +lastErrorCode +lastErrorMessage +``` + +--- + +# 90. Locking + +V1 usa locking pesimista/transaccional por `RecurringProforma`. + +Objetivo: + +```text +solo una ejecución puede avanzar simultáneamente +nextOccurrenceDate +``` + +Segunda barrera defensiva: + +```text +UNIQUE(recurring_proforma_id, scheduled_date) +``` + +No introducir distributed lock externo salvo necesidad real del despliegue. + +--- + +# 91. Scheduler + +Infrastructure contiene un worker periódico. + +Debe limitarse a invocar Application. + +Conceptualmente: + +```text +Infrastructure scheduler + ↓ +ProcessDueRecurringProformasUseCase +``` + +No contiene: + +- cálculo de calendario; +- creación de Proforma; +- lógica fiscal; +- transiciones de dominio. + +--- + +# 92. Procesamiento por recurrencia + +Para cada recurrencia: + +```text +procesar occurrences vencidas +en orden cronológico +de forma secuencial +``` + +Recurrencias distintas pueden procesarse independientemente. + +Un fallo en una recurrencia no impide procesar las demás. + +--- + +# 93. Recovery + +Puede existir: + +```text +RecoverStaleRecurringProformaOccurrencesUseCase +``` + +o integrarse de forma limpia en el worker principal. + +La lógica debe permanecer en Application. + +--- + +# 94. Auditoría + +Eventos funcionales V1: + +```text +recurring_proforma.created +recurring_proforma.updated +recurring_proforma.activated +recurring_proforma.paused +recurring_proforma.resumed +recurring_proforma.completed +recurring_proforma.archived +recurring_proforma.deleted + +recurring_proforma_occurrence.generated +recurring_proforma_occurrence.failed +recurring_proforma_occurrence.retried +``` + +No auditar eventos puramente técnicos: + +```text +pending → processing +lock acquired +lease recovered +``` + +--- + +# 95. Estado auditable de occurrence + +Debe incluir al menos: + +```text +scheduledDate +status +attemptCount +proformaId +lastErrorCode +``` + +`generated` debe permitir identificar la Proforma creada. + +--- + +# 96. Permisos + +V1 no introduce permisos específicos de recurrencias. + +Se reutiliza la autorización existente del módulo `customer-invoices`. + +El worker ejecuta como sistema. + +La auditoría debe distinguir: + +```text +usuario +sistema +``` + +--- + +# 97. API HTTP + +Base: + +```text +/recurring-proformas +``` + +Todas las operaciones de creación relacionadas con recurrencias quedan bajo este recurso. + +--- + +# 98. CRUD + +```text +GET /recurring-proformas +POST /recurring-proformas +GET /recurring-proformas/:recurring_proforma_id +PATCH /recurring-proformas/:recurring_proforma_id +DELETE /recurring-proformas/:recurring_proforma_id +``` + +--- + +# 99. Readiness y lifecycle + +```text +GET /recurring-proformas/:recurring_proforma_id/activation-readiness + +PATCH /recurring-proformas/:recurring_proforma_id/activate +PATCH /recurring-proformas/:recurring_proforma_id/pause +PATCH /recurring-proformas/:recurring_proforma_id/resume +PATCH /recurring-proformas/:recurring_proforma_id/complete +PATCH /recurring-proformas/:recurring_proforma_id/archive +``` + +`status` no es editable mediante PATCH general. + +--- + +# 100. Ocurrencias + +```text +GET /recurring-proformas/:recurring_proforma_id/occurrences + +GET /recurring-proformas/:recurring_proforma_id/occurrences/:occurrence_id +``` + +--- + +# 101. Acciones manuales + +```text +POST /recurring-proformas/:recurring_proforma_id/generate-next-occurrence + +POST /recurring-proformas/:recurring_proforma_id/occurrences/:occurrence_id/retry +``` + +--- + +# 102. Creación desde Proforma + +```text +POST /recurring-proformas/from-proforma/:proforma_id +``` + +--- + +# 103. Duplicación + +```text +POST /recurring-proformas/:recurring_proforma_id/duplicate +``` + +--- + +# 104. DTO CREATE + +`POST /recurring-proformas` puede contener solamente: + +```json +{ + "name": "Mantenimiento mensual" +} +``` + +El backend crea: + +```text +status = draft +``` + +No acepta: + +```text +id +company_id +status +next_occurrence_date +created_at +updated_at +``` + +--- + +# 105. Semántica PATCH + +Regla general: + +```text +omitido +→ no modificar + +null +→ ausencia explícita / quitar override cuando aplique + +valor +→ configurar explícitamente +``` + +Ejemplo: + +```json +{ + "payment_method_id": null +} +``` + +significa: + +```text +eliminar override +→ volver a herencia +``` + +--- + +# 106. PATCH collections + +```text +items omitido +→ no tocar + +items enviado +→ reemplazo completo +``` + +No introducir actualización granular de items. + +--- + +# 107. DTO Schedule + +Shape conceptual: + +```json +{ + "start_date": "2026-09-01", + "end_date": null, + "unit": "month", + "interval": 1, + "monthly_rule": "same_day" +} +``` + +En PATCH el objeto puede modificarse parcialmente. + +Ejemplo: + +```json +{ + "schedule": { + "end_date": "2027-12-31" + } +} +``` + +Application reconstruye y valida el `RecurrenceSchedule` completo. + +--- + +# 108. DTOs exactos + +Todo valor exacto debe seguir las convenciones existentes del ERP: + +```text +Money +Amount +Percentage +Quantity +``` + +No deben viajar como `number` cuando requieran exactitud decimal. + +Los schemas Zod son SSOT. + +Los snapshot builders deben tiparse contra los DTO inferidos. + +--- + +# 109. Responses heredables + +El frontend debe conocer: + +```text +valor configurado +valor actualmente resuelto +``` + +Ejemplo conceptual: + +```json +{ + "payment_method": { + "configured_id": null, + "resolved": { + "id": "...", + "name": "Transferencia" + } + } +} +``` + +No es necesario un campo redundante: + +```text +source = inherited +``` + +porque puede derivarse de `configured_id`. + +--- + +# 110. Listado HTTP + +Filtros V1: + +```text +search +status +customer_id +has_issue +next_occurrence_date_from +next_occurrence_date_to +page +per_page +sort +``` + +Búsqueda rápida al menos por: + +```text +name +description +``` + +y por Customer si el Finder actual permite hacerlo de forma consistente con otros listados. + +--- + +# 111. Ordenación + +Campos útiles: + +```text +name +status +next_occurrence_date +created_at +updated_at +``` + +La lista debe seguir las mismas convenciones de paginación y sorting ya utilizadas en el ERP. + +--- + +# 112. Frontend + +Nombre visible: + +```text +Proformas recurrentes +``` + +No usar: + +```text +Facturación recurrente +``` + +porque V1 no emite facturas automáticamente. + +--- + +# 113. Navegación + +Dentro de facturación de clientes: + +```text +Facturación de clientes +├─ Proformas +├─ Proformas archivadas +├─ Proformas recurrentes +├─ Proformas recurrentes archivadas +└─ Facturas emitidas +``` + +--- + +# 114. Rutas frontend + +Conceptualmente: + +```text +/recurring-proformas +/recurring-proformas/create +/recurring-proformas/:recurring_proforma_id +/recurring-proformas/:recurring_proforma_id/update + +/recurring-proformas/archived +``` + +--- + +# 115. Listado frontend + +Debe reutilizar el mismo patrón visual que el listado actual de Proformas: + +- filtros; +- tabla; +- sorting; +- paginación; +- búsqueda; +- acciones; +- loading; +- errores; +- componentes Rdx existentes. + +Columnas recomendadas: + +```text +Nombre +Cliente +Estado +Frecuencia +Próxima generación +Incidencias +Última actualización +Acciones +``` + +--- + +# 116. Filtro segmentado + +Utilizar un control equivalente a: + +```text +ProformaStatusSegmentedFilter +``` + +Estados operativos: + +```text +Todas +Borrador +Activas +Pausadas +Completadas +``` + +`archived` no aparece ahí. + +Las archivadas tienen entrada propia en menú lateral. + +--- + +# 117. Archivadas + +Ruta: + +```text +/recurring-proformas/archived +``` + +Debe reutilizar exactamente el mismo listado y tabla con filtro fijo: + +```text +status = archived +``` + +--- + +# 118. Incidencias + +Una incidencia NO es estado de `RecurringProforma`. + +Visualmente: + +```text +Activa + Incidencia +``` + +`hasIssue` deriva de una occurrence `failed`. + +Debe existir filtro: + +```text +Todas +Con incidencias +Sin incidencias +``` + +--- + +# 119. Editor + +Secciones recomendadas: + +```text +Identificación interna +Cliente +Contenido comercial +Condiciones heredables +Calendario +``` + +--- + +# 120. Identificación interna UI + +Campos: + +```text +Nombre +Descripción interna +``` + +Debe indicarse que: + +```text +description +``` + +es exclusivamente interna y no se copia a Proformas. + +--- + +# 121. UI de herencia + +Debe distinguir claramente: + +```text +Heredar +vs +override explícito +``` + +Ejemplo: + +```text +Forma de pago +[ Heredar del cliente ] Transferencia actual +``` + +No mostrar simplemente el valor resuelto como si estuviera fijado. + +--- + +# 122. Preview de calendario + +El editor debe mostrar aproximadamente las próximas 3–5 fechas calculadas. + +Ejemplo: + +```text +Próximas fechas + +01/09/2026 +01/10/2026 +01/11/2026 +01/12/2026 +``` + +La lógica debe coincidir exactamente con `RecurrenceSchedule`. + +--- + +# 123. Activación UI + +Antes de activar: + +```text +GET activation-readiness +``` + +Si existen blockers: + +```text +→ mostrar lista funcional traducida +``` + +El backend vuelve a validar igualmente durante la transición. + +--- + +# 124. Historial UI + +Tabla de occurrences: + +```text +Fecha programada +Estado +Proforma +Factura emitida +Intentos +Último intento +Acciones +``` + +--- + +# 125. Estados occurrence en UI + +```text +pending → Pendiente +processing → Procesando +failed → Fallida +generated → Generada +``` + +`processing` es solo informativo. + +No tiene acciones manuales. + +--- + +# 126. Errores UI + +`lastErrorCode` es la fuente para mensajes funcionales traducidos. + +`lastErrorMessage`: + +- no se muestra crudo en tabla; +- puede utilizarse en detalle técnico/soporte cuando aporte valor. + +--- + +# 127. Generate next UI + +Solo disponible en `active`. + +Si la fecha futura se genera anticipadamente, el modal debe explicar: + +- cuál era la fecha programada; +- que esa occurrence será consumida; +- que la Proforma usará la fecha actual de generación. + +--- + +# 128. Retry UI + +Solo visible para: + +```text +Occurrence.status = failed +``` + +Debe indicar que: + +```text +el retry utiliza la configuración actual de la recurrencia +``` + +No existe acción: + +```text +Skip +``` + +--- + +# 129. Pause UI + +Debe advertir: + +```text +la pausa no desplaza ni elimina fechas +``` + +Las occurrences vencidas durante la pausa se recuperarán al reanudar. + +--- + +# 130. Resume UI + +Si hay occurrences vencidas, puede informar de que serán procesadas al reanudar. + +--- + +# 131. Completed / Archived UI + +Ambos read-only. + +Acciones: + +```text +completed +→ Duplicate +→ Archive + +archived +→ Duplicate +``` + +--- + +# 132. Matriz de acciones UI + +| Estado | Editar | Activar | Pausar | Reanudar | Generar próxima | Completar | Archivar | Duplicar | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| draft | Sí | Sí | No | No | No | Sí | No | Sí | +| active | Sí | No | Sí | No | Sí | Sí* | No | Sí | +| paused | Sí | No | No | Sí | No | Sí* | No | Sí | +| completed | No | No | No | No | No | No | Sí | Sí | +| archived | No | No | No | No | No | No | No | Sí | + +`*` No disponible si existe occurrence `failed`. + +--- + +# 133. Testing obligatorio + +Debe cubrirse específicamente: + +## Calendario + +- week; +- month; +- year; +- interval > 1; +- día 31; +- 29 febrero; +- last day; +- endDate inclusiva; +- endDate entre occurrences; +- conservación del ancla. + +## Estados + +Todas las transiciones válidas e inválidas. + +## Draft + +Creación únicamente con `name`. + +## Herencia + +Para todos los campos heredables: + +- inherited; +- explicit; +- cambio posterior de defaults; +- recurso explícito inactivo; +- eliminación de override; +- no materialización del valor heredado en la recurrencia. + +## Customer + +- inactive; +- cambio de Customer; +- compatibilidad; +- efecto solo futuro. + +## Generación + +- success; +- fiscalidad actual; +- recipient actual; +- descuentos; +- series; +- fechas; +- nextOccurrenceDate. + +## Idempotencia + +Ejecutar dos veces: + +```text +(recurringProformaId, scheduledDate) +``` + +debe producir: + +```text +1 occurrence +1 Proforma +``` + +## Concurrencia + +Dos workers sobre la misma recurrencia: + +```text +→ exactamente una generación efectiva +``` + +## Atomicidad + +Si falla la generación: + +- no queda Proforma; +- no avanza calendario; +- occurrence termina `failed`. + +## Retry + +- usa configuración actual; +- misma occurrence; +- incrementa intentos; +- no duplica Proforma. + +## Recovery + +`processing` abandonado debe recuperarse sin duplicados. + +## Pause/resume + +Debe recuperar fechas vencidas sin desplazar calendario. + +## Generate next + +Debe diferenciar: + +```text +scheduledDate +generationDate +``` + +## EndDate + +Debe completar automáticamente al agotarse el calendario. + +## Delete + +Solo: + +```text +draft + sin occurrences +``` + +## From Proforma + +Debe copiar únicamente configuración comercial válida y respetar procedencia cuando sea demostrable. + +## Duplicate + +Debe crear: + +```text +nuevo UUID +draft +sin occurrences +sin nextOccurrenceDate +``` + +--- + +# 134. Criterio E2E principal de aceptación + +V1 debe demostrar correctamente este escenario: + +```text +1. Crear RecurringProforma draft. +2. Guardarla inicialmente solo con name. +3. Configurar Customer, líneas y calendario mensual. +4. Configurar campos heredados y overrides. +5. Ejecutar activation-readiness. +6. Activar. +7. Generar primera Proforma automáticamente. +8. Comprobar recipient actual. +9. Comprobar campos comerciales. +10. Comprobar fiscalidad e impuestos actuales. +11. Comprobar totales. +12. Desactivar un PaymentMethod explícito. +13. Ejecutar siguiente occurrence. +14. La occurrence falla. +15. No se crea Proforma. +16. No se procesa la occurrence posterior. +17. Cambiar PaymentMethod. +18. Retry. +19. Se genera exactamente una Proforma para esa scheduledDate. +20. El calendario continúa. +21. Pausar. +22. Dejar vencer varias fechas. +23. Reanudar. +24. Procesar las fechas atrasadas en orden. +25. Alcanzar endDate. +26. Completar automáticamente. +27. Archivar. +28. Consultar historial. +29. Navegar a las Proformas generadas. +30. Resolver de forma derivada las IssuedInvoices resultantes. +``` + +--- + +# 135. Decisiones anti-overengineering + +No introducir en V1: + +```text +Subscription +Contract +RecurringBillingEngine +GenericScheduler +GenericRecurrence +Domain Event Bus +CQRS formal +OccurrenceAttempt +snapshot comercial por occurrence +snapshot fiscal por occurrence +cron configurable +timezone por recurrencia +skip +cancel occurrence +generación extraordinaria +auto issue +auto VeriFactu +products +articles +price lists +period expressions +template variables +business day calendars +holiday calendars +``` + +Preferencia permanente: + +```text +claridad semántica +> +abstracción anticipada +``` + +--- + +# 136. Regla arquitectónica final + +La separación funcional debe mantenerse: + +```text +RecurringProforma +→ define intención comercial futura y calendario + +RecurringProformaOccurrence +→ registra ejecución de una fecha lógica + +Proforma +→ representa el documento comercial generado + +IssuedInvoice +→ representa el snapshot fiscal definitivo tras emisión +``` + +Ningún agregado debe asumir responsabilidades pertenecientes a otro. + +En particular: + +```text +RecurringProforma +``` + +no calcula fiscalidad ni emite facturas. + +```text +RecurringProformaOccurrence +``` + +no sigue el lifecycle posterior de la Proforma. + +```text +Proforma +``` + +no conoce que fue creada por una recurrencia. + +La colaboración entre estos conceptos se orquesta desde Application. + +--- + +# 137. Regla final de V1 + +`recurring-proformas` debe proporcionar una automatización: + +```text +explícita +auditable +idempotente +recuperable +tenant-scoped +fiscalmente segura +sin emisión automática +``` + +La característica debe priorizar: + +```text +generar correctamente una única Proforma +para cada occurrence lógica +``` + +por encima de cualquier automatización adicional. + +La evolución posterior hacia emisión automática, períodos avanzados, variables dinámicas o conceptos de suscripción deberá diseñarse como extensión explícita y no condicionar innecesariamente V1. \ No newline at end of file diff --git a/modules/catalogs/package.json b/modules/catalogs/package.json index 1d9995c6..013d6dd8 100644 --- a/modules/catalogs/package.json +++ b/modules/catalogs/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts index 01227d04..ddecc205 100644 --- a/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts +++ b/modules/catalogs/src/api/application/tax-definitions/public/services/tax-definition-public-finder.interface.ts @@ -7,12 +7,14 @@ export interface FindActiveTaxDefinitionByCodeParams { companyId: UniqueID; code: string; atDate: UtcDate; + transaction?: unknown; } export interface FindActiveTaxDefinitionsByCodesParams { companyId: UniqueID; codes: Collection; atDate: UtcDate; + transaction?: unknown; } export interface ITaxDefinitionPublicFinder { diff --git a/modules/catalogs/src/api/index.ts b/modules/catalogs/src/api/index.ts index b11a763e..aa2395c3 100644 --- a/modules/catalogs/src/api/index.ts +++ b/modules/catalogs/src/api/index.ts @@ -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; - -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"; diff --git a/modules/catalogs/src/api/module.test.ts b/modules/catalogs/src/api/module.test.ts new file mode 100644 index 00000000..26f8da16 --- /dev/null +++ b/modules/catalogs/src/api/module.test.ts @@ -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"); +}); diff --git a/modules/catalogs/src/api/module.ts b/modules/catalogs/src/api/module.ts new file mode 100644 index 00000000..ead06513 --- /dev/null +++ b/modules/catalogs/src/api/module.ts @@ -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; + +/** 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, + }; + }, +}; diff --git a/modules/companies/package.json b/modules/companies/package.json index d18b8516..5df633a0 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/modules/companies/src/api/index.ts b/modules/companies/src/api/index.ts index c3526004..d171f1c5 100644 --- a/modules/companies/src/api/index.ts +++ b/modules/companies/src/api/index.ts @@ -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; diff --git a/modules/companies/src/api/infrastructure/di/companies.di.ts b/modules/companies/src/api/infrastructure/di/companies.di.ts index 37a1983f..1f9fc4f4 100644 --- a/modules/companies/src/api/infrastructure/di/companies.di.ts +++ b/modules/companies/src/api/infrastructure/di/companies.di.ts @@ -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("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, diff --git a/modules/companies/src/api/module.test.ts b/modules/companies/src/api/module.test.ts new file mode 100644 index 00000000..22503c18 --- /dev/null +++ b/modules/companies/src/api/module.test.ts @@ -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"); +}); diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts new file mode 100644 index 00000000..cd394c7f --- /dev/null +++ b/modules/companies/src/api/module.ts @@ -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 }; + }, +}; diff --git a/modules/core/src/api/infrastructure/audit/index.ts b/modules/core/src/api/infrastructure/audit/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/core/src/api/infrastructure/audit/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/core/src/api/infrastructure/audit/sequelize/audit-change.model.ts b/modules/core/src/api/infrastructure/audit/sequelize/audit-change.model.ts new file mode 100644 index 00000000..baa8e8bf --- /dev/null +++ b/modules/core/src/api/infrastructure/audit/sequelize/audit-change.model.ts @@ -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, InferCreationAttributes> { + 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; } diff --git a/modules/core/src/api/infrastructure/audit/sequelize/audit-event.model.ts b/modules/core/src/api/infrastructure/audit/sequelize/audit-event.model.ts new file mode 100644 index 00000000..7cf3b662 --- /dev/null +++ b/modules/core/src/api/infrastructure/audit/sequelize/audit-event.model.ts @@ -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, InferCreationAttributes> { + 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; +} diff --git a/modules/core/src/api/infrastructure/audit/sequelize/index.ts b/modules/core/src/api/infrastructure/audit/sequelize/index.ts new file mode 100644 index 00000000..83415caf --- /dev/null +++ b/modules/core/src/api/infrastructure/audit/sequelize/index.ts @@ -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]; diff --git a/modules/core/src/api/infrastructure/audit/sequelize/sequelize-audit-store.ts b/modules/core/src/api/infrastructure/audit/sequelize/sequelize-audit-store.ts new file mode 100644 index 00000000..95107d76 --- /dev/null +++ b/modules/core/src/api/infrastructure/audit/sequelize/sequelize-audit-store.ts @@ -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, + private readonly changeModel: ModelStatic, + private readonly transactionContext: ISequelizeTransactionContext + ) {} + async append( + entry: AuditEntry, + options?: AuditRecordOptions + ): Promise> { + 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.")); + } + } +} diff --git a/modules/core/src/api/infrastructure/index.ts b/modules/core/src/api/infrastructure/index.ts index ff3a5dbc..399abfb9 100644 --- a/modules/core/src/api/infrastructure/index.ts +++ b/modules/core/src/api/infrastructure/index.ts @@ -1,4 +1,5 @@ export * from "./config"; +export * from "./audit"; export * from "./context"; export * from "./database"; export * from "./di"; diff --git a/modules/core/src/api/infrastructure/persistence/sequelize/database-config.ts b/modules/core/src/api/infrastructure/persistence/sequelize/database-config.ts new file mode 100644 index 00000000..5cb268c9 --- /dev/null +++ b/modules/core/src/api/infrastructure/persistence/sequelize/database-config.ts @@ -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; +} diff --git a/modules/core/src/api/infrastructure/persistence/sequelize/index.ts b/modules/core/src/api/infrastructure/persistence/sequelize/index.ts index b172a3c9..33218d42 100644 --- a/modules/core/src/api/infrastructure/persistence/sequelize/index.ts +++ b/modules/core/src/api/infrastructure/persistence/sequelize/index.ts @@ -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"; diff --git a/modules/core/src/api/infrastructure/persistence/sequelize/sequelize-factory.ts b/modules/core/src/api/infrastructure/persistence/sequelize/sequelize-factory.ts new file mode 100644 index 00000000..b4a7bf4c --- /dev/null +++ b/modules/core/src/api/infrastructure/persistence/sequelize/sequelize-factory.ts @@ -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 { + 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 { + try { + await database.close(); + logger.info("Database connection closed", { label: "database" }); + } catch (error) { + logger.error("Error while closing database", { label: "database", error }); + } +} diff --git a/modules/core/src/api/modules/index.ts b/modules/core/src/api/modules/index.ts index 24f3693a..29fb8cbe 100644 --- a/modules/core/src/api/modules/index.ts +++ b/modules/core/src/api/modules/index.ts @@ -1,2 +1,4 @@ export * from "./module-server.interface"; +export * from "./module-runtime"; +export * from "./modules-runtime"; export * from "./types"; diff --git a/modules/core/src/api/modules/module-runtime.ts b/modules/core/src/api/modules/module-runtime.ts new file mode 100644 index 00000000..3adfd2b0 --- /dev/null +++ b/modules/core/src/api/modules/module-runtime.ts @@ -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(); +const services: Record = {}; +const models: ModelEntry[] = []; +const setupOrder: string[] = []; +const setupDone = new Set(); +const visiting = new Set(); +const internals = new Map>(); +const usedDependencies = new Map>(); +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 { + 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 { + 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 { + 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 { + if (modelsInitialized) return; + const registered = new Map(); + 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 { + 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 (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(name); } return getService(`${moduleName}:${key.join(":")}`); }; } +function makeGetInternal(moduleName: string) { return (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(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(); 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(); const undeclared = [...used].filter((dependency) => !declared.has(dependency)); if (undeclared.length) throw new Error(`Module "${name}" used undeclared dependencies: ${undeclared.join(", ")}`); } } +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms (${label})`)), timeoutMs); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }) as Promise; } +export function listServices(): string[] { return Object.keys(services); } +export function getServiceScoped(requesterModule: string, allowedDependencies: readonly string[], name: string): T { + const [moduleName, ...key] = name.split(":"); + if (moduleName === "self") return getService(`${requesterModule}:${key.join(":")}`); + if (!allowedDependencies.includes(moduleName)) throw new Error(`Module "${requesterModule}" tried to access service "${name}" without declaring dependency on "${moduleName}"`); + return getService(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; } diff --git a/modules/core/src/api/modules/modules-runtime.ts b/modules/core/src/api/modules/modules-runtime.ts new file mode 100644 index 00000000..ece56a0e --- /dev/null +++ b/modules/core/src/api/modules/modules-runtime.ts @@ -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; + }; + }; +} diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index ec95314b..9e0a2c9b 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index 52599ce7..1dbec3e3 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -2,3 +2,4 @@ export * from "./company-verifactu-profile"; export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; +export * from "./recurring-proformas"; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts index afaaa634..541452dd 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-creator.di.ts @@ -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), + }); }; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts index 085f8aa1..76b98005 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.test.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.test.ts new file mode 100644 index 00000000..a64bf2ff --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.test.ts @@ -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 = (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); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.ts new file mode 100644 index 00000000..829e2b1b --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-config-resolver.ts @@ -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> { + 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; + usesEquivalenceSurcharge: boolean; + requestedCode?: Maybe; + transaction?: unknown; + }): Promise, 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; + transaction?: unknown; + }): Promise, 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 | undefined): string | null { + return value?.isSome() ? value.unwrap() : null; + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts index 13272dff..0d0fa49f 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/catalog-resolver/proforma-tax-resolver.ts @@ -14,18 +14,21 @@ import type { ProformaItemTaxCodesInput } from "../../models"; export interface EnsureProformaTaxRegimeByCodeParams { companyId: UniqueID; code: Maybe; + 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; - }): Promise> { + public async ensureTaxRegimeByCode( + params: EnsureProformaTaxRegimeByCodeParams + ): Promise> { 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, }); } diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index c0da5177..13f5d203 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -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> extends Result< - infer T, - Error - > - ? T - : never; - override?: ProformaTaxConfigInputProps; - }): Promise> { - 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> extends Result< - infer T, - Error - > - ? T - : never - ): string | null { - return null; - } - - private async resolveDefaultRecCode(params: { - companyId: UniqueID; - atDate: IProformaCreateProps["proformaDate"]; - defaultIvaCode: Maybe; - usesEquivalenceSurcharge: boolean; - requestedCode?: Maybe; - }): Promise, 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; - }): Promise, 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 = { - 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 | 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; - } } diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/audit/index.ts b/modules/customer-invoices/src/api/application/recurring-proformas/audit/index.ts new file mode 100644 index 00000000..92f6cc0c --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/audit/index.ts @@ -0,0 +1 @@ +export * from "./recurring-proforma-audit"; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/audit/recurring-proforma-audit.ts b/modules/customer-invoices/src/api/application/recurring-proformas/audit/recurring-proforma-audit.ts new file mode 100644 index 00000000..0e61a934 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/audit/recurring-proforma-audit.ts @@ -0,0 +1,37 @@ +import type { RecurringProforma } from "../../../domain"; + +const nullable = (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, +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/index.ts b/modules/customer-invoices/src/api/application/recurring-proformas/index.ts new file mode 100644 index 00000000..6066b63f --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/index.ts @@ -0,0 +1,4 @@ +export * from "./repositories"; +export * from "./audit"; +export * from "./models"; +export * from "./services"; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/models/index.ts b/modules/customer-invoices/src/api/application/recurring-proformas/models/index.ts new file mode 100644 index 00000000..9b1e3c97 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/models/index.ts @@ -0,0 +1 @@ +export * from "./recurring-proforma-read.models"; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts b/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts new file mode 100644 index 00000000..a49e20d4 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/models/recurring-proforma-read.models.ts @@ -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 = { + 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; +}; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/repositories/index.ts b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/index.ts new file mode 100644 index 00000000..9f7a65a0 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/index.ts @@ -0,0 +1,2 @@ +export * from "./recurring-proforma-repository.interface"; +export * from "./recurring-proforma-occurrence-repository.interface"; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-occurrence-repository.interface.ts b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-occurrence-repository.interface.ts new file mode 100644 index 00000000..4d682190 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-occurrence-repository.interface.ts @@ -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>; + findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + findByRecurringProformaAndScheduledDate( + recurringProformaId: UniqueID, + scheduledDate: Date, + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>>; + latestScheduledDate( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>>; + hasFailedOccurrences( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise>; + listByRecurringProformaInCompany( + query: ListRecurringProformaOccurrencesQuery, + transaction?: unknown + ): Promise, Error>>; +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-repository.interface.ts b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-repository.interface.ts new file mode 100644 index 00000000..266736b4 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/repositories/recurring-proforma-repository.interface.ts @@ -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>; + findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + findByIdInCompanyForUpdate( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + deleteByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + hasOccurrences( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise>; + findDue( + dueThrough: Date, + limit: number + ): Promise>; + listInCompany( + query: ListRecurringProformasQuery, + transaction?: unknown + ): Promise, Error>>; +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/create-recurring-proforma-occurrence-processor.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/create-recurring-proforma-occurrence-processor.ts new file mode 100644 index 00000000..afc2ab93 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/create-recurring-proforma-occurrence-processor.ts @@ -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); +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/index.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/index.ts new file mode 100644 index 00000000..b7a5fd76 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-activation-readiness-checker.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-activation-readiness-checker.ts new file mode 100644 index 00000000..bf40c1ee --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-activation-readiness-checker.ts @@ -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> { + 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 }); + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts new file mode 100644 index 00000000..93cf0c31 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.test.ts @@ -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 = (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 (work: (currentTransaction: unknown) => Promise) => 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 = {}; + const patch = { paymentTermId: null }; + const commands = new RecurringProformaCommands({ + creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe; 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; 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); +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts new file mode 100644 index 00000000..92267167 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-capabilities.ts @@ -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, Error>> { + return this.transactionManager.complete((transaction) => + this.occurrenceRepository.listByRecurringProformaInCompany(query, transaction) + ); + } + + private toDetail(source: RecurringProforma): RecurringProformaDetailReadModel { + const schedule = source.schedule.map((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 & { 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, + }); + }); + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.test.ts new file mode 100644 index 00000000..3ae1c154 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.test.ts @@ -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 = (result: { data: T }): T => result.data; + +const buildRecurringItem = (overrides?: Partial): 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); +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.ts new file mode 100644 index 00000000..e030a83f --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-from-proforma-creator.ts @@ -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 & { endDate?: Date | null }; + transaction: unknown; + }): Promise> { + 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); + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.test.ts new file mode 100644 index 00000000..744de179 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.test.ts @@ -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); +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.ts new file mode 100644 index 00000000..8d9f62a2 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-materializer.ts @@ -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> { + 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> { + 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 }, params: { companyId: UniqueID; transaction: unknown }): Promise, 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); + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.test.ts new file mode 100644 index 00000000..3d2d4617 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.test.ts @@ -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 = (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; + 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) => 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) => 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) => { + 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) => { + 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) => { + 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()); +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.ts new file mode 100644 index 00000000..391de39f --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-occurrence-processor.ts @@ -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> { + 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> { + 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 { + 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; } + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.test.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.test.ts new file mode 100644 index 00000000..fa215585 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.test.ts @@ -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 = (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); +}); diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.ts new file mode 100644 index 00000000..f8875081 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-services.ts @@ -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; transaction?: unknown }): Promise> { + 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> { + 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> { + 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> { + 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); + } +} diff --git a/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-updater.ts b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-updater.ts new file mode 100644 index 00000000..1e05eba4 --- /dev/null +++ b/modules/customer-invoices/src/api/application/recurring-proformas/services/recurring-proforma-updater.ts @@ -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 & { 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> { + 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> { + 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> { + 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 { + const operations: Array> = []; + 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); + } +} diff --git a/modules/customer-invoices/src/api/domain/index.ts b/modules/customer-invoices/src/api/domain/index.ts index 828b821f..8d948274 100644 --- a/modules/customer-invoices/src/api/domain/index.ts +++ b/modules/customer-invoices/src/api/domain/index.ts @@ -3,3 +3,4 @@ export * from "./company-verifactu-profile"; export * from "./invoice-series"; export * from "./issued-invoices"; export * from "./proformas"; +export * from "./recurring-proformas"; diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/index.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/index.ts new file mode 100644 index 00000000..c4d5f747 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.test.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.test.ts new file mode 100644 index 00000000..9eb221e6 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RecurrenceSchedule } from "./recurrence-schedule"; + +const required = (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); +}); diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.ts new file mode 100644 index 00000000..fc8f7a99 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurrence-schedule.ts @@ -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 { + private static readonly FIELD = "recurrenceSchedule"; + + public static create(props: { + startDate: Date; + endDate?: Date | null; + unit: RecurrenceUnit; + interval: number; + monthlyRule?: MonthlyRule | null; + }): Result { + 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 { + 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()); + } +} diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.test.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.test.ts new file mode 100644 index 00000000..c01e80f8 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.test.ts @@ -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 = (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"); +}); diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.ts new file mode 100644 index 00000000..f47ba64a --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-item.ts @@ -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; + quantity: Maybe; + unitAmount: Maybe; + itemDiscountPercentage: Maybe; + ivaCode?: Maybe; + recCode?: Maybe; + retentionCode?: Maybe; +} + +type RecurringProformaItemInternalProps = Omit & { + ivaCode: Maybe; + recCode: Maybe; + retentionCode: Maybe; +}; + +export class RecurringProformaItem extends DomainEntity { + public static create( + props: RecurringProformaItemCreateProps, + id?: UniqueID + ): Result { + 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 { + return this.props.description; + } + + public get quantity(): Maybe { + return this.props.quantity; + } + + public get unitAmount(): Maybe { + return this.props.unitAmount; + } + + public get itemDiscountPercentage(): Maybe { + return this.props.itemDiscountPercentage; + } + + public get ivaCode(): Maybe { return this.props.ivaCode; } + + public get recCode(): Maybe { return this.props.recCode; } + + public get retentionCode(): Maybe { return this.props.retentionCode; } + + public getProps(): RecurringProformaItemCreateProps { + return { + ...this.props, + ivaCode: this.ivaCode, + recCode: this.recCode, + retentionCode: this.retentionCode, + }; + } +} diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.test.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.test.ts new file mode 100644 index 00000000..8e62b3f4 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.test.ts @@ -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 = (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"] + ); +}); diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.ts new file mode 100644 index 00000000..99e6dfac --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-items.ts @@ -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 { + private constructor(props: RecurringProformaItemsProps) { + super(props.items ?? []); + } + + public static create(props: RecurringProformaItemsProps): RecurringProformaItems { + return new RecurringProformaItems(props); + } +} diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.test.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.test.ts new file mode 100644 index 00000000..b4d13ac5 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.test.ts @@ -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 = (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); +}); diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.ts new file mode 100644 index 00000000..5fd72382 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma-occurrence.ts @@ -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; + processingStartedAt?: Maybe; + lastErrorCode?: Maybe; + lastErrorMessage?: Maybe; + proformaId?: Maybe; + generatedAt?: Maybe; + createdAt: Date; + updatedAt: Date; +} + +type RecurringProformaOccurrenceInternalProps = { + companyId: UniqueID; + recurringProformaId: UniqueID; + scheduledDate: Date; + status: RecurringProformaOccurrenceStatus; + attemptCount: number; + lastAttemptAt: Maybe; + processingStartedAt: Maybe; + lastErrorCode: Maybe; + lastErrorMessage: Maybe; + proformaId: Maybe; + generatedAt: Maybe; + createdAt: Date; + updatedAt: Date; +}; + +export class RecurringProformaOccurrence extends DomainEntity { + private static readonly FIELD = "recurringProformaOccurrence"; + + public static create( + props: RecurringProformaOccurrenceCreateProps, + id?: UniqueID + ): Result { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.props.lastAttemptAt.map((value) => + RecurringProformaOccurrence.cloneDate(value) + ); + } + + public get processingStartedAt(): Maybe { + return this.props.processingStartedAt.map((value) => + RecurringProformaOccurrence.cloneDate(value) + ); + } + + public get lastErrorCode(): Maybe { + return this.props.lastErrorCode; + } + + public get lastErrorMessage(): Maybe { + return this.props.lastErrorMessage; + } + + public get proformaId(): Maybe { + return this.props.proformaId; + } + + public get generatedAt(): Maybe { + 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 { + 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()); + } +} diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.test.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.test.ts new file mode 100644 index 00000000..3956013b --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.test.ts @@ -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 = (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); +}); diff --git a/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.ts b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.ts new file mode 100644 index 00000000..fef6ffa0 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/recurring-proformas/recurring-proforma.ts @@ -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; + globalDiscountPercentage?: Maybe; + customerId?: Maybe; + schedule?: Maybe; + nextOccurrenceDate?: Maybe; + paymentTermId?: Maybe; + paymentMethodId?: Maybe; + language?: Maybe; + currency?: Maybe; + proformaSeries?: Maybe; + issuedInvoiceSeries?: Maybe; + createdAt?: Date; + updatedAt?: Date; + items?: RecurringProformaItemCreateProps[]; + status?: RecurringProformaStatus; +} + +export interface RecurringProformaInternalProps { + companyId: UniqueID; + name: string; + description: Maybe; + globalDiscountPercentage: Maybe; + customerId: Maybe; + schedule: Maybe; + status: RecurringProformaStatus; + nextOccurrenceDate: Maybe; + paymentTermId: Maybe; + paymentMethodId: Maybe; + language: Maybe; + currency: Maybe; + proformaSeries: Maybe; + issuedInvoiceSeries: Maybe; + createdAt: Date; + updatedAt: Date; +} + +export class RecurringProforma extends AggregateRoot { + 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 { + 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 { + 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 { + 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): Result { + 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): Result { + 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): Result { + return this.setMaybeOverride( + this.props.globalDiscountPercentage, + value, + (next) => { + this.props.globalDiscountPercentage = next; + }, + (left, right) => left.equals(right) + ); + } + public setPaymentTermId(value: Maybe): Result { + return this.setMaybeOverride( + this.props.paymentTermId, + value, + (next) => { + this.props.paymentTermId = next; + }, + (left, right) => left.equals(right) + ); + } + public setPaymentMethodId(value: Maybe): Result { + return this.setMaybeOverride( + this.props.paymentMethodId, + value, + (next) => { + this.props.paymentMethodId = next; + }, + (left, right) => left.equals(right) + ); + } + public setLanguage(value: Maybe): Result { + return this.setMaybeOverride( + this.props.language, + value, + (next) => { + this.props.language = next; + }, + (left, right) => left.equals(right) + ); + } + public setCurrency(value: Maybe): Result { + return this.setMaybeOverride( + this.props.currency, + value, + (next) => { + this.props.currency = next; + }, + (left, right) => left.equals(right) + ); + } + public setProformaSeries(value: Maybe): Result { + return this.setMaybeOverride( + this.props.proformaSeries, + value, + (next) => { + this.props.proformaSeries = next; + }, + (left, right) => left.equals(right) + ); + } + public setIssuedInvoiceSeries(value: Maybe): Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.props.description; + } + + public get globalDiscountPercentage(): Maybe { + return this.props.globalDiscountPercentage; + } + + public get customerId(): Maybe { + return this.props.customerId; + } + + public get schedule(): Maybe { + return this.props.schedule; + } + + public get status(): RecurringProformaStatus { + return this.props.status; + } + + public get nextOccurrenceDate(): Maybe { + return this.props.nextOccurrenceDate.map((value) => RecurringProforma.cloneDate(value)); + } + + public get paymentTermId(): Maybe { + return this.props.paymentTermId; + } + + public get paymentMethodId(): Maybe { + return this.props.paymentMethodId; + } + + public get language(): Maybe { + return this.props.language; + } + + public get currency(): Maybe { + return this.props.currency; + } + + public get proformaSeries(): Maybe { + return this.props.proformaSeries; + } + + public get issuedInvoiceSeries(): Maybe { + 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 { + 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 { + 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( + current: Maybe, + value: Maybe, + assign: (value: Maybe) => void, + equals: (left: T, right: T) => boolean + ): Result { + 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( + left: Maybe, + right: Maybe, + 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 { + 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()); + } +} diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index c62aed65..fa84ed8f 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -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, diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts index 0fd6f066..8fed183d 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/index.ts @@ -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, ]; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts index ed9d41fd..22f3570f 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-item.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-item.model.ts new file mode 100644 index 00000000..0c04896a --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-item.model.ts @@ -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, + InferCreationAttributes +> { + 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; + + 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; +}; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-models.test.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-models.test.ts new file mode 100644 index 00000000..98ec2762 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-models.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Sequelize } from "sequelize"; + +import recurringProformaItemModelInit, { + RecurringProformaItemModel, +} from "./recurring-proforma-item.model"; +import recurringProformaOccurrenceModelInit, { + RecurringProformaOccurrenceModel, +} from "./recurring-proforma-occurrence.model"; +import recurringProformaModelInit, { RecurringProformaModel } from "./recurring-proforma.model"; + +test("modelos de recurring proformas declaran tablas, nulabilidad e índices contractuales", () => { + const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }); + recurringProformaModelInit(database); + recurringProformaItemModelInit(database); + recurringProformaOccurrenceModelInit(database); + + assert.equal(RecurringProformaModel.getTableName(), "recurring_proformas"); + assert.equal(RecurringProformaModel.getAttributes().schedule_start_date.allowNull, true); + assert.equal(RecurringProformaModel.getAttributes().payment_term_id.allowNull, true); + assert.equal(RecurringProformaItemModel.options.timestamps, false); + assert.equal(RecurringProformaOccurrenceModel.getTableName(), "recurring_proforma_occurrences"); + assert.equal( + RecurringProformaOccurrenceModel.options.indexes?.some( + (index) => index.name === "uq_recurring_proforma_occurrences_schedule" && index.unique === true + ), + true + ); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-occurrence.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-occurrence.model.ts new file mode 100644 index 00000000..3c6d5a6b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma-occurrence.model.ts @@ -0,0 +1,78 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type NonAttribute, + type Sequelize, +} from "sequelize"; + +import type { ProformaModel } from "./sequelize-proforma.model"; +import type { RecurringProformaModel } from "./recurring-proforma.model"; + +export class RecurringProformaOccurrenceModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare company_id: string; + declare recurring_proforma_id: string; + declare scheduled_date: string; + declare status: string; + declare attempt_count: number; + declare last_attempt_at: CreationOptional; + declare processing_started_at: CreationOptional; + declare last_error_code: CreationOptional; + declare last_error_message: CreationOptional; + declare proforma_id: CreationOptional; + declare generated_at: CreationOptional; + declare created_at: CreationOptional; + declare updated_at: CreationOptional; + declare recurring_proforma: NonAttribute; + declare proforma: NonAttribute; + + public static associate(database: Sequelize): void { + const { RecurringProformaModel, RecurringProformaOccurrenceModel, ProformaModel } = database.models; + if (!RecurringProformaModel || !RecurringProformaOccurrenceModel || !ProformaModel) { + throw new Error("[RecurringProformaOccurrenceModel.associate] Missing required models"); + } + RecurringProformaOccurrenceModel.belongsTo(RecurringProformaModel, { as: "recurring_proforma", foreignKey: "recurring_proforma_id", onDelete: "RESTRICT", onUpdate: "CASCADE" }); + RecurringProformaOccurrenceModel.belongsTo(ProformaModel, { as: "proforma", foreignKey: "proforma_id", onDelete: "RESTRICT", onUpdate: "CASCADE" }); + } +} + +export default (database: Sequelize) => { + RecurringProformaOccurrenceModel.init( + { + id: { type: DataTypes.UUID, primaryKey: true } as any, + company_id: { type: DataTypes.UUID, allowNull: false } as any, + recurring_proforma_id: { type: DataTypes.UUID, allowNull: false } as any, + scheduled_date: { type: DataTypes.DATEONLY, allowNull: false }, + status: { type: DataTypes.STRING(16), allowNull: false }, + attempt_count: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0 }, + last_attempt_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, + processing_started_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, + last_error_code: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, + last_error_message: { type: DataTypes.TEXT, allowNull: true, defaultValue: null }, + proforma_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null } as any, + generated_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, + }, + { + sequelize: database, + modelName: "RecurringProformaOccurrenceModel", + tableName: "recurring_proforma_occurrences", + underscored: true, + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + indexes: [ + { name: "uq_recurring_proforma_occurrences_schedule", fields: ["recurring_proforma_id", "scheduled_date"], unique: true }, + { name: "uq_recurring_proforma_occurrences_proforma", fields: ["proforma_id"], unique: true }, + { name: "idx_recurring_proforma_occurrences_company_status", fields: ["company_id", "status"] }, + { name: "idx_recurring_proforma_occurrences_company_status_processing", fields: ["company_id", "status", "processing_started_at"] }, + ], + } + ); + return RecurringProformaOccurrenceModel; +}; diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma.model.ts new file mode 100644 index 00000000..bdfe50ce --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/recurring-proforma.model.ts @@ -0,0 +1,105 @@ +import { + type CreationOptional, + DataTypes, + type InferAttributes, + type InferCreationAttributes, + Model, + type NonAttribute, + type Sequelize, +} from "sequelize"; + +import type { RecurringProformaItemModel } from "./recurring-proforma-item.model"; +import type { RecurringProformaOccurrenceModel } from "./recurring-proforma-occurrence.model"; + +export class RecurringProformaModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare company_id: string; + declare name: string; + declare description: CreationOptional; + declare global_discount_percentage_value: CreationOptional; + declare global_discount_percentage_scale: CreationOptional; + declare status: string; + declare customer_id: CreationOptional; + declare payment_term_id: CreationOptional; + declare payment_method_id: CreationOptional; + declare language: CreationOptional; + declare currency: CreationOptional; + declare proforma_series_code: CreationOptional; + declare issued_invoice_series_code: CreationOptional; + declare schedule_start_date: CreationOptional; + declare schedule_end_date: CreationOptional; + declare schedule_unit: CreationOptional; + declare schedule_interval: CreationOptional; + declare schedule_monthly_rule: CreationOptional; + declare next_occurrence_date: CreationOptional; + declare created_at: CreationOptional; + declare updated_at: CreationOptional; + declare items: NonAttribute; + declare occurrences: NonAttribute; + + public static associate(database: Sequelize): void { + const { RecurringProformaModel, RecurringProformaItemModel, RecurringProformaOccurrenceModel } = + database.models; + + if (!RecurringProformaModel || !RecurringProformaItemModel || !RecurringProformaOccurrenceModel) { + throw new Error("[RecurringProformaModel.associate] Missing recurring proforma models"); + } + + RecurringProformaModel.hasMany(RecurringProformaItemModel, { + as: "items", + foreignKey: "recurring_proforma_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + }); + RecurringProformaModel.hasMany(RecurringProformaOccurrenceModel, { + as: "occurrences", + foreignKey: "recurring_proforma_id", + onDelete: "RESTRICT", + onUpdate: "CASCADE", + }); + } +} + +export default (database: Sequelize) => { + RecurringProformaModel.init( + { + id: { type: DataTypes.UUID, primaryKey: true } as any, + company_id: { type: DataTypes.UUID, allowNull: false } as any, + name: { type: DataTypes.STRING(), allowNull: false }, + description: { type: DataTypes.TEXT(), allowNull: true, defaultValue: null }, + global_discount_percentage_value: { type: DataTypes.SMALLINT, allowNull: true, defaultValue: null }, + global_discount_percentage_scale: { type: DataTypes.SMALLINT, allowNull: true, defaultValue: null }, + status: { type: DataTypes.STRING(16), allowNull: false }, + customer_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null } as any, + payment_term_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null } as any, + payment_method_id: { type: DataTypes.UUID, allowNull: true, defaultValue: null } as any, + language: { type: DataTypes.STRING(2), allowNull: true, defaultValue: null }, + currency: { type: DataTypes.STRING(3), allowNull: true, defaultValue: null }, + proforma_series_code: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null }, + issued_invoice_series_code: { type: DataTypes.STRING(32), allowNull: true, defaultValue: null }, + schedule_start_date: { type: DataTypes.DATEONLY, allowNull: true, defaultValue: null }, + schedule_end_date: { type: DataTypes.DATEONLY, allowNull: true, defaultValue: null }, + schedule_unit: { type: DataTypes.STRING(8), allowNull: true, defaultValue: null }, + schedule_interval: { type: DataTypes.SMALLINT.UNSIGNED, allowNull: true, defaultValue: null }, + schedule_monthly_rule: { type: DataTypes.STRING(20), allowNull: true, defaultValue: null }, + next_occurrence_date: { type: DataTypes.DATEONLY, allowNull: true, defaultValue: null }, + }, + { + sequelize: database, + modelName: "RecurringProformaModel", + tableName: "recurring_proformas", + underscored: true, + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + indexes: [ + { name: "idx_recurring_proformas_company_status_next", fields: ["company_id", "status", "next_occurrence_date"] }, + { name: "idx_recurring_proformas_company_customer", fields: ["company_id", "customer_id"] }, + ], + } + ); + return RecurringProformaModel; +}; diff --git a/modules/customer-invoices/src/api/infrastructure/index.ts b/modules/customer-invoices/src/api/infrastructure/index.ts index c3e898aa..a401eda6 100644 --- a/modules/customer-invoices/src/api/infrastructure/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/index.ts @@ -2,3 +2,4 @@ export * from "./common"; export * from "./company-verifactu-profile"; export * from "./issued-invoices"; export * from "./proformas"; +export * from "./recurring-proformas"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.test.ts new file mode 100644 index 00000000..b2105ddb --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Sequelize } from "sequelize"; + +import { buildProformasDependencies } from "./proformas.di"; + +test("buildProformasDependencies expone capabilities recurrentes neutrales y conserva el processor worker", () => { + const finder = {}; + const dependencies = buildProformasDependencies({ + database: new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }), + transactionContext: {}, + audit: { recordMutation: async () => ({ isFailure: false, isSuccess: true, data: undefined }) }, + config: { + paths: { fastReportBin: "/tmp/fastreport", templates: "/tmp/templates", signedDocuments: "/tmp/documents" }, + signingService: { url: "http://localhost", method: "POST", timeoutMs: { maxRetries: 0 } }, + }, + getService: (name: string) => { + if (name.startsWith("catalogs:")) return { finder }; + return {}; + }, + } as never); + + const recurring = dependencies.recurringProformas; + for (const capability of [ + recurring.list, recurring.detail, recurring.occurrences, + recurring.create, recurring.update, recurring.activationReadiness, + recurring.activate, recurring.pause, recurring.resume, recurring.complete, + recurring.archive, recurring.duplicate, recurring.delete, recurring.fromProforma, + recurring.occurrenceProcessor.process, + ]) { + assert.equal(typeof capability, "function"); + } + assert.equal(typeof recurring.repository.findDue, "function"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index e69354b5..ffc15574 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -46,6 +46,19 @@ import { buildReportProformaPdfUseCase, buildUnarchiveProformaByIdUseCase, buildUpdateProformaUseCase, + createRecurringProformaOccurrenceProcessor, + ProformaTaxConfigResolver, + RecurringProformaMaterializer, + RecurringProformaActivationReadinessChecker, + RecurringProformaCommands, + RecurringProformaCreator, + RecurringProformaDeleter, + RecurringProformaDuplicator, + RecurringProformaFromProformaCreator, + type RecurringProformaOccurrenceProcessor, + RecurringProformaReader, + RecurringProformaStatusChanger, + RecurringProformaUpdater, } from "../../../application"; import { CompanyVerifactuProfileDomainMapper, @@ -58,8 +71,32 @@ import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/ import { buildProformaDocumentServices } from "./proforma-documents.di"; import { buildV2ProformaPersistence } from "./proforma-persistence.di"; import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di"; +import { + SequelizeRecurringProformaOccurrenceRepository, + SequelizeRecurringProformaRepository, +} from "../../recurring-proformas/persistence/sequelize/repositories"; export type ProformasInternalDeps = { + recurringProformas: { + occurrenceProcessor: RecurringProformaOccurrenceProcessor; + repository: SequelizeRecurringProformaRepository; + reader: RecurringProformaReader; + commands: RecurringProformaCommands; + list: RecurringProformaReader["list"]; + detail: RecurringProformaReader["detail"]; + occurrences: RecurringProformaReader["occurrences"]; + create: RecurringProformaCommands["create"]; + update: RecurringProformaCommands["update"]; + activationReadiness: RecurringProformaCommands["activationReadiness"]; + activate: RecurringProformaCommands["activate"]; + pause: RecurringProformaCommands["pause"]; + resume: RecurringProformaCommands["resume"]; + complete: RecurringProformaCommands["complete"]; + archive: RecurringProformaCommands["archive"]; + duplicate: RecurringProformaCommands["duplicate"]; + delete: RecurringProformaCommands["delete"]; + fromProforma: RecurringProformaCommands["fromProforma"]; + }; useCases: { listProformas: () => ListProformasUseCase; getProformaById: () => GetProformaByIdUseCase; @@ -125,6 +162,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter documentSeriesServices ); + const taxConfigResolver = new ProformaTaxConfigResolver(catalogResolvers.taxResolver); const creator = buildProformaCreator({ taxResolver: catalogResolvers.taxResolver, paymentResolver: catalogResolvers.paymentResolver, @@ -135,6 +173,80 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter companiesServices, customerServices, audit: params.audit, + taxConfigResolver, + }); + const recurringProformaRepository = new SequelizeRecurringProformaRepository(database); + const occurrenceRepository = new SequelizeRecurringProformaOccurrenceRepository(); + const recurringReadinessChecker = new RecurringProformaActivationReadinessChecker({ + customerServices, + paymentResolver: catalogResolvers.paymentResolver, + proformaSeriesValidator, + targetInvoiceSeriesValidator, + }); + const recurringCreator = new RecurringProformaCreator({ + repository: recurringProformaRepository, + audit: params.audit, + }); + const recurringUpdater = new RecurringProformaUpdater({ + repository: recurringProformaRepository, + occurrenceRepository, + customerServices, + paymentResolver: catalogResolvers.paymentResolver, + proformaSeriesValidator, + targetInvoiceSeriesValidator, + audit: params.audit, + }); + const recurringStatusChanger = new RecurringProformaStatusChanger({ + repository: recurringProformaRepository, + occurrenceRepository, + readinessChecker: recurringReadinessChecker, + audit: params.audit, + }); + const recurringDeleter = new RecurringProformaDeleter({ + repository: recurringProformaRepository, + audit: params.audit, + }); + const recurringDuplicator = new RecurringProformaDuplicator({ + repository: recurringProformaRepository, + audit: params.audit, + }); + const recurringFromProformaCreator = new RecurringProformaFromProformaCreator({ + proformaRepository: repository, + recurringProformaRepository, + audit: params.audit, + }); + const recurringReader = new RecurringProformaReader( + recurringProformaRepository, + occurrenceRepository, + transactionManager + ); + const recurringCommands = new RecurringProformaCommands({ + creator: recurringCreator, + updater: recurringUpdater, + statusChanger: recurringStatusChanger, + deleter: recurringDeleter, + duplicator: recurringDuplicator, + fromProformaCreator: recurringFromProformaCreator, + readinessChecker: recurringReadinessChecker, + repository: recurringProformaRepository, + transactionManager, + }); + const recurringMaterializer = new RecurringProformaMaterializer({ + customerServices, + companiesServices, + paymentResolver: catalogResolvers.paymentResolver, + taxConfigResolver, + taxResolver: catalogResolvers.taxResolver, + proformaSeriesValidator, + targetInvoiceSeriesValidator, + proformaCreator: creator, + }); + const occurrenceProcessor = createRecurringProformaOccurrenceProcessor({ + transactionManager, + recurringProformaRepository, + occurrenceRepository, + materializer: recurringMaterializer, + audit: params.audit, }); const duplicator = buildProformaDuplicator({ repository, @@ -195,6 +307,26 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const documentGeneratorServices = buildProformaDocumentServices(params); return { + recurringProformas: { + occurrenceProcessor, + repository: recurringProformaRepository, + reader: recurringReader, + commands: recurringCommands, + list: recurringReader.list.bind(recurringReader), + detail: recurringReader.detail.bind(recurringReader), + occurrences: recurringReader.occurrences.bind(recurringReader), + create: recurringCommands.create.bind(recurringCommands), + update: recurringCommands.update.bind(recurringCommands), + activationReadiness: recurringCommands.activationReadiness.bind(recurringCommands), + activate: recurringCommands.activate.bind(recurringCommands), + pause: recurringCommands.pause.bind(recurringCommands), + resume: recurringCommands.resume.bind(recurringCommands), + complete: recurringCommands.complete.bind(recurringCommands), + archive: recurringCommands.archive.bind(recurringCommands), + duplicate: recurringCommands.duplicate.bind(recurringCommands), + delete: recurringCommands.delete.bind(recurringCommands), + fromProforma: recurringCommands.fromProforma.bind(recurringCommands), + }, useCases: { listProformas: () => buildListProformasUseCase({ diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.test.ts new file mode 100644 index 00000000..2de6dfa6 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.test.ts @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Criteria } from "@repo/rdx-criteria/server"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Request, Response } from "express"; + +import { ListProformasController } from "./list-proformas.controller"; + +function createResponseDouble() { + return { + statusCode: 200, + headers: {} as Record, + body: undefined as unknown, + type() { + return this; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + sendStatus(code: number) { + this.statusCode = code; + return this; + }, + setHeader(key: string, value: string) { + this.headers[key] = value; + return this; + }, + set(values: Record) { + Object.assign(this.headers, values); + return this; + }, + send(payload?: unknown) { + this.body = payload; + return this; + }, + }; +} + +function createRequestDouble(originalUrl: string): Request { + return { + method: "GET", + protocol: "http", + originalUrl, + query: {}, + params: {}, + body: {}, + get: () => undefined, + user: { + userId: UniqueID.generateNewID(), + companyId: UniqueID.generateNewID(), + }, + } as Request; +} + +test("acepta filtros por rango sobre proforma_date", async () => { + let useCaseExecuted = false; + + const controller = new ListProformasController( + { + execute: async ({ criteria }: { criteria: { toPrimitives(): { filters: unknown[] } } }) => { + useCaseExecuted = true; + + const filters = criteria.toPrimitives().filters as Array<{ + field: string; + value?: string; + }>; + + assert.deepEqual( + filters.map((filter) => ({ + field: filter.field, + value: filter.value, + })), + [ + { + field: "proforma_date", + value: "2026-01-01", + }, + { + field: "proforma_date", + value: "2026-08-29", + }, + ] + ); + + return Result.ok({ + page: 0, + per_page: 25, + total_pages: 0, + total_items: 0, + items: [], + metadata: { + entity: "proformas", + criteria: {}, + status_counts: { + all: 0, + draft: 0, + sent: 0, + approved: 0, + rejected: 0, + issued: 0, + }, + }, + }); + }, + } as never, + "normal" + ); + + const res = createResponseDouble(); + const req = createRequestDouble("/api/proformas"); + + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).req = req; + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).res = + res as never as Response; + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).criteria = + Criteria.fromPrimitives( + [ + { + field: "proforma_date", + operator: "GREATER_THAN_OR_EQUAL", + value: "2026-01-01", + }, + { + field: "proforma_date", + operator: "LOWER_THAN_OR_EQUAL", + value: "2026-08-29", + }, + ], + "proforma_date", + "desc", + 25, + 0, + "" + ); + + await ( + controller as unknown as { + executeImpl(): Promise; + } + ).executeImpl(); + + assert.equal(res.statusCode, 200); + assert.equal(useCaseExecuted, true); +}); + +test("rechaza operadores no soportados para proforma_date", async () => { + const controller = new ListProformasController( + { + execute: async () => { + throw new Error("should not run"); + }, + } as never, + "normal" + ); + + const res = createResponseDouble(); + const req = createRequestDouble("/api/proformas"); + + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).req = req; + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).res = + res as never as Response; + (controller as unknown as { req: Request; res: Response; criteria: Criteria }).criteria = + Criteria.fromPrimitives( + [ + { + field: "proforma_date", + operator: "EQUALS", + value: "2026-08-29", + }, + ], + "proforma_date", + "desc", + 25, + 0, + "" + ); + + await ( + controller as unknown as { + executeImpl(): Promise; + } + ).executeImpl(); + + assert.equal(res.statusCode, 422); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts index 41316900..c93f902a 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts @@ -12,6 +12,7 @@ import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; export class ListProformasController extends ExpressController { private static readonly ALLOWED_FILTERS = { status: new Set(["EQUALS"]), + proforma_date: new Set([">=", "<="]), } as const; private static readonly ALLOWED_STATUSES = new Set([ @@ -38,8 +39,14 @@ export class ListProformasController extends ExpressController { } private getCriteriaWithDefaultOrder() { - const { q: quicksearch, filters, pageSize, pageNumber, orderBy, orderType } = - this.criteria.toPrimitives(); + const { + q: quicksearch, + filters, + pageSize, + pageNumber, + orderBy, + orderType, + } = this.criteria.toPrimitives(); return Criteria.fromPrimitives( filters, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index 4637a770..758d8d16 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -32,8 +32,10 @@ import { UnarchiveProformaByIdParamsRequestSchema, UpdateProformaByIdParamsRequestSchema, UpdateProformaByIdRequestSchema, + CreateRecurringProformaFromProformaRequestSchema, } from "../../../../common"; import type { IIssuedInvoicePublicServices } from "../../../application"; +import { CreateRecurringProformaFromProformaController } from "../../recurring-proformas/express/controllers"; import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller"; import { CreateProformaController } from "./controllers/create-proforma.controller"; @@ -176,6 +178,18 @@ export const proformasRouter = (params: StartParams) => { } ); + router.post( + "/:proforma_id/recurring-proforma", + validateRequest(GetProformaByIdRequestSchema, "params"), + validateRequest(CreateRecurringProformaFromProformaRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new CreateRecurringProformaFromProformaController( + deps.recurringProformas.fromProforma + ); + return controller.execute(req, res, next); + } + ); + router.delete( "/:proforma_id", validateRequest(DeleteProformaByIdParamsRequestSchema, "params"), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index 6f03538e..75108873 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -50,6 +50,7 @@ export class SequelizeProformaRepositoryV2 { private static readonly ALLOWED_FILTERS = { status: new Set(["EQUALS"]), + proforma_date: new Set([">=", "<="]), } as const; constructor( @@ -693,7 +694,6 @@ export class SequelizeProformaRepositoryV2 [Op.ne]: null, }, } as WhereOptions>; - case "normal": default: return { archived_at: null, diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.test.ts new file mode 100644 index 00000000..8619192e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CreateRecurringProformaFromProformaController } from "./create-recurring-proforma-from-proforma.controller"; + +const request = (companyId: UniqueID, proformaId: UniqueID, body: unknown) => ({ + user: { companyId }, + params: { proforma_id: proformaId.toPrimitive() }, + body, + query: {}, + originalUrl: "/proformas/x/recurring-proforma", + method: "POST", + protocol: "http", + get: () => undefined, +}); + +const response = () => { + let statusCode: number | undefined; + let body: unknown; + const res = { + status: (value: number) => { + statusCode = value; + return res; + }, + json: (value: unknown) => { + body = value; + return res; + }, + sendStatus: () => res, + type: () => res, + setHeader: () => undefined, + }; + + return { res, getStatusCode: () => statusCode, getBody: () => body }; +}; + +const monthlySchedule = { + startDate: "2026-09-01", + endDate: "2026-12-31", + unit: "month" as const, + interval: 1, + monthlyRule: "same_day" as const, +}; + +test("from-proforma delegates the minimal input and returns the created recurrence", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const fromProformaCalls: Array> = []; + const httpResponse = response(); + const controller = new CreateRecurringProformaFromProformaController( + (async (input: Record) => { + fromProformaCalls.push(input); + return Result.ok({ id: recurringProformaId, status: "draft" }); + }) as never + ); + + await controller.execute( + request(companyId, proformaId, { + name: "Mantenimiento mensual", + schedule: monthlySchedule, + }) as never, + httpResponse.res as never, + () => undefined + ); + + assert.equal(fromProformaCalls.length, 1); + assert.equal((fromProformaCalls[0]?.companyId as UniqueID).equals(companyId), true); + assert.equal((fromProformaCalls[0]?.proformaId as UniqueID).equals(proformaId), true); + assert.equal(fromProformaCalls[0]?.name, "Mantenimiento mensual"); + assert.deepEqual(Object.keys(fromProformaCalls[0] ?? {}).sort(), [ + "companyId", + "name", + "proformaId", + "schedule", + ]); + const schedule = fromProformaCalls[0]?.schedule as { + startDate: Date; + endDate: Date; + }; + assert.equal(schedule.startDate instanceof Date, true); + assert.equal(schedule.endDate instanceof Date, true); + assert.equal(schedule.startDate.toISOString().slice(0, 10), "2026-09-01"); + assert.equal(schedule.endDate.toISOString().slice(0, 10), "2026-12-31"); + assert.equal(httpResponse.getStatusCode(), 201); + assert.deepEqual(httpResponse.getBody(), { + id: recurringProformaId.toPrimitive(), + status: "draft", + }); +}); + +test("from-proforma preserves a null end date", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const fromProformaCalls: Array> = []; + const httpResponse = response(); + const controller = new CreateRecurringProformaFromProformaController( + (async (input: Record) => { + fromProformaCalls.push(input); + return Result.ok({ id: UniqueID.generateNewID(), status: "draft" }); + }) as never + ); + + await controller.execute( + request(companyId, proformaId, { + name: "Mantenimiento mensual", + schedule: { ...monthlySchedule, endDate: null }, + }) as never, + httpResponse.res as never, + () => undefined + ); + + assert.equal(fromProformaCalls.length, 1); + assert.equal( + (fromProformaCalls[0]?.schedule as { endDate: Date | null }).endDate, + null + ); +}); + +test("from-proforma rejects an invalid date before invoking Application", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const fromProformaCalls: Array> = []; + const httpResponse = response(); + const controller = new CreateRecurringProformaFromProformaController( + (async (input: Record) => { + fromProformaCalls.push(input); + return Result.ok({ id: UniqueID.generateNewID(), status: "draft" }); + }) as never + ); + + await controller.execute( + request(companyId, proformaId, { + name: "Mantenimiento mensual", + schedule: { ...monthlySchedule, startDate: "2026-02-30" }, + }) as never, + httpResponse.res as never, + () => undefined + ); + + assert.equal(fromProformaCalls.length, 0); + assert.equal(httpResponse.getStatusCode(), 400); +}); + +test("from-proforma propagates an Application failure", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const fromProformaCalls: Array> = []; + const httpResponse = response(); + const controller = new CreateRecurringProformaFromProformaController( + (async (input: Record) => { + fromProformaCalls.push(input); + return Result.fail(new Error("Proforma not found")); + }) as never + ); + + await controller.execute( + request(companyId, proformaId, { + name: "Mantenimiento mensual", + schedule: monthlySchedule, + }) as never, + httpResponse.res as never, + () => undefined + ); + + assert.equal(fromProformaCalls.length, 1); + assert.equal(httpResponse.getStatusCode(), 500); + assert.deepEqual(httpResponse.getBody(), { + type: "https://httpstatuses.com/500", + title: "Internal Server Error", + status: 500, + detail: "Proforma not found ", + instance: "/proformas/x/recurring-proforma", + method: "POST", + }); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.ts new file mode 100644 index 00000000..f11609a7 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma-from-proforma.controller.ts @@ -0,0 +1,68 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; + +import type { CreateRecurringProformaFromProformaRequestDTO } from "../../../../../common"; +import type { RecurringProformaCommands } from "../../../../application"; +import { proformasApiErrorMapper } from "../../../proformas/express/proformas-api-error-mapper"; +import { parseDateOnly } from "../mappers/parse-date-only"; + +export class CreateRecurringProformaFromProformaController extends ExpressController { + public constructor( + private readonly fromProforma: RecurringProformaCommands["fromProforma"] + ) { + super(); + this.errorMapper = proformasApiErrorMapper; + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId"), + forbidQueryFieldGuard("company_id") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + const proformaId = UniqueID.create(this.req.params.proforma_id); + + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (proformaId.isFailure) return this.invalidInputError(proformaId.error.message); + + const body = this.req.body as CreateRecurringProformaFromProformaRequestDTO; + const startDate = parseDateOnly(body.schedule.startDate); + if (startDate.isFailure) return this.invalidInputError(startDate.error.message); + + const endDate = body.schedule.endDate; + const parsedEndDate = + typeof endDate === "string" ? parseDateOnly(endDate) : undefined; + if (parsedEndDate?.isFailure) { + return this.invalidInputError(parsedEndDate.error.message); + } + + const result = await this.fromProforma({ + companyId, + proformaId: proformaId.data, + name: body.name, + schedule: { + startDate: startDate.data, + endDate: endDate === null ? null : parsedEndDate?.data, + unit: body.schedule.unit, + interval: body.schedule.interval, + monthlyRule: body.schedule.monthlyRule, + }, + }); + + return result.match( + (recurringProforma) => + this.created({ + id: recurringProforma.id.toPrimitive(), + status: recurringProforma.status, + }), + (error) => this.handleError(error) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts new file mode 100644 index 00000000..3c77581e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/create-recurring-proforma.controller.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CreateRecurringProformaRequestSchema } from "../../../../../common"; + +test("CreateRecurringProformaRequestSchema acepta un draft con solo name", () => { + assert.deepEqual(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual" }), { name: "Mantenimiento mensual" }); +}); + +test("CreateRecurringProformaRequestSchema conserva null explícito en description", () => { + assert.equal(CreateRecurringProformaRequestSchema.parse({ name: "Mantenimiento mensual", description: null }).description, null); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/index.ts new file mode 100644 index 00000000..d14c508a --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./recurring-proformas-read.controllers"; +export * from "./create-recurring-proforma-from-proforma.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts new file mode 100644 index 00000000..72a8b3d4 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proforma-transitions.controller.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { ArchiveRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { DuplicateRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { DeleteRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { ActivateRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { PauseRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { ResumeRecurringProformaController } from "./recurring-proformas-read.controllers"; +import { CompleteRecurringProformaController } from "./recurring-proformas-read.controllers"; + +test("archive success delegates once and returns HTTP success", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const archiveCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new ArchiveRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + archiveCalls.push(input); + return Result.ok({ id: recurringProformaId.toPrimitive(), status: "archived" }); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/archive", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(archiveCalls.length, 1); + assert.equal(archiveCalls[0]?.companyId.equals(companyId), true); + assert.equal(archiveCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 200); + assert.deepEqual(body, { id: recurringProformaId.toPrimitive(), status: "archived" }); +}); + +test("archive failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const archiveCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new ArchiveRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + archiveCalls.push(input); + return Result.fail(new Error("Invalid recurring proforma transition")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/archive", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(archiveCalls.length, 1); + assert.equal(archiveCalls[0]?.companyId.equals(companyId), true); + assert.equal(archiveCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("duplicate success delegates only the command identifiers and returns HTTP success", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const duplicatedId = UniqueID.generateNewID(); + const duplicateCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new DuplicateRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + duplicateCalls.push(input); + return Result.ok({ id: duplicatedId.toPrimitive(), status: "draft" }); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/duplicate", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(duplicateCalls.length, 1); + assert.equal(duplicateCalls[0]?.companyId.equals(companyId), true); + assert.equal(duplicateCalls[0]?.id.equals(recurringProformaId), true); + assert.deepEqual(Object.keys(duplicateCalls[0] ?? {}).sort(), ["companyId", "id"]); + assert.equal(statusCode, 200); + assert.deepEqual(body, { id: duplicatedId.toPrimitive(), status: "draft" }); +}); + +test("duplicate failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const duplicateCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new DuplicateRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + duplicateCalls.push(input); + return Result.fail(new Error("Recurring proforma not found")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/duplicate", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(duplicateCalls.length, 1); + assert.equal(duplicateCalls[0]?.companyId.equals(companyId), true); + assert.equal(duplicateCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("delete success delegates once and returns HTTP success", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const deleteCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new DeleteRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + deleteCalls.push(input); + return Result.ok(true); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x", method: "DELETE", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(deleteCalls.length, 1); + assert.equal(deleteCalls[0]?.companyId.equals(companyId), true); + assert.equal(deleteCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 200); + assert.equal(body, true); +}); + +test("delete failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const deleteCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new DeleteRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + deleteCalls.push(input); + return Result.fail(new Error("Only draft recurring proformas can be deleted")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x", method: "DELETE", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(deleteCalls.length, 1); + assert.equal(deleteCalls[0]?.companyId.equals(companyId), true); + assert.equal(deleteCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("activate failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const activateCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new ActivateRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + activateCalls.push(input); + return Result.fail(new Error("Recurring proforma cannot be activated: missing_customer")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/activate", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(activateCalls.length, 1); + assert.equal(activateCalls[0]?.companyId.equals(companyId), true); + assert.equal(activateCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("pause failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const pauseCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new PauseRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + pauseCalls.push(input); + return Result.fail(new Error("Invalid recurring proforma transition")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/pause", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(pauseCalls.length, 1); + assert.equal(pauseCalls[0]?.companyId.equals(companyId), true); + assert.equal(pauseCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("resume failure delegates once and returns the mapped functional error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const resumeCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new ResumeRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + resumeCalls.push(input); + return Result.fail(new Error("Invalid recurring proforma transition")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/resume", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(resumeCalls.length, 1); + assert.equal(resumeCalls[0]?.companyId.equals(companyId), true); + assert.equal(resumeCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.equal(typeof body, "object"); +}); + +test("complete failure for failed occurrences delegates once and returns the mapped error", async () => { + const companyId = UniqueID.generateNewID(); + const recurringProformaId = UniqueID.generateNewID(); + const completeCalls: Array<{ companyId: typeof companyId; id: typeof recurringProformaId }> = []; + let statusCode: number | undefined; + let body: unknown; + const controller = new CompleteRecurringProformaController(async (input: { companyId: typeof companyId; id: typeof recurringProformaId }) => { + completeCalls.push(input); + return Result.fail(new Error("Recurring proforma has unresolved failed occurrences")); + }); + const req = { user: { companyId }, params: { recurring_proforma_id: recurringProformaId.toPrimitive() }, query: {}, originalUrl: "/recurring-proformas/x/complete", method: "POST", protocol: "http", get: () => undefined }; + const res = { status: (value: number) => { statusCode = value; return res; }, json: (value: unknown) => { body = value; return res; }, sendStatus: () => res, type: () => res, setHeader: () => undefined }; + await controller.execute(req as never, res as never, () => undefined); + assert.equal(completeCalls.length, 1); + assert.equal(completeCalls[0]?.companyId.equals(companyId), true); + assert.equal(completeCalls[0]?.id.equals(recurringProformaId), true); + assert.equal(statusCode, 500); + assert.deepEqual(body, { + type: "https://httpstatuses.com/500", + title: "Internal Server Error", + status: 500, + detail: "Recurring proforma has unresolved failed occurrences ", + instance: "/recurring-proformas/x/complete", + method: "POST", + }); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts new file mode 100644 index 00000000..241a9268 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/recurring-proformas-read.controllers.ts @@ -0,0 +1,219 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; + +import { proformasApiErrorMapper } from "../../../proformas/express/proformas-api-error-mapper"; +import { + RecurringProformaDetailPresenter, + RecurringProformaListPresenter, + RecurringProformaOccurrencePresenter, +} from "../presenters"; +import { RecurringProformaReadinessPresenter } from "../presenters/recurring-proforma-readiness.presenter"; + +const dateOnly = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : null); + +const id = (value: UniqueID | null) => value?.toPrimitive() ?? null; + +const schedule = ( + value: { + startDate: Date; + endDate: Date | null; + unit: string; + interval: number; + monthlyRule: string | null; + } | null +) => + value && { + start_date: dateOnly(value.startDate), + end_date: dateOnly(value.endDate), + unit: value.unit, + interval: value.interval, + monthly_rule: value.monthlyRule, + }; + +const listPresenter = (page: any) => ({ + page: page.page, + per_page: page.perPage, + total_items: page.totalItems, + total_pages: page.totalPages, + items: page.items.map((item: any) => ({ + id: item.id.toPrimitive(), + name: item.name, + customer_id: id(item.customerId), + status: item.status, + schedule: schedule(item.schedule), + next_occurrence_date: dateOnly(item.nextOccurrenceDate), + updated_at: item.updatedAt.toISOString(), + })), +}); + +abstract class RecurringProformaController extends ExpressController { + public constructor() { + super(); + this.errorMapper = proformasApiErrorMapper; + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + protected company() { + const value = this.getTenantId(); + return value || null; + } +} +export class ListRecurringProformasController extends RecurringProformaController { + public constructor( + private readonly list: any, + private readonly presenter = new RecurringProformaListPresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + const result = await this.list({ companyId, ...this.req.query }); + return result.match( + (data: any) => this.ok(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +export class GetRecurringProformaController extends RecurringProformaController { + public constructor( + private readonly detail: any, + private readonly presenter = new RecurringProformaDetailPresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + const parsed = UniqueID.create(this.req.params.recurring_proforma_id); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (parsed.isFailure) return this.invalidInputError(parsed.error.message); + const result = await this.detail({ companyId, id: parsed.data }); + return result.match( + (data: any) => this.ok(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +export class ListRecurringProformaOccurrencesController extends RecurringProformaController { + public constructor( + private readonly occurrences: any, + private readonly presenter = new RecurringProformaOccurrencePresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + const parsed = UniqueID.create(this.req.params.recurring_proforma_id); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (parsed.isFailure) return this.invalidInputError(parsed.error.message); + const result = await this.occurrences({ + companyId, + recurringProformaId: parsed.data, + ...this.req.query, + }); + return result.match( + (data: any) => this.ok(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +export class CreateRecurringProformaController extends RecurringProformaController { + public constructor( + private readonly create: any, + private readonly presenter = new RecurringProformaDetailPresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + const result = await this.create({ companyId, ...this.req.body }); + return result.match( + (data: any) => this.created(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +export class UpdateRecurringProformaController extends RecurringProformaController { + public constructor( + private readonly update: any, + private readonly presenter = new RecurringProformaDetailPresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + const parsed = UniqueID.create(this.req.params.recurring_proforma_id); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (parsed.isFailure) return this.invalidInputError(parsed.error.message); + const body = this.req.body; + const paymentTermId = + body.payment_term_id === undefined + ? undefined + : body.payment_term_id === null + ? null + : UniqueID.create(body.payment_term_id).data; + const patch = { + name: body.name, + description: body.description, + paymentTermId, + language: body.language, + proformaSeries: body.proforma_series, + }; + const result = await this.update({ companyId, id: parsed.data, patch }); + return result.match( + (data: any) => this.ok(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +export class GetRecurringProformaActivationReadinessController extends RecurringProformaController { + public constructor( + private readonly readiness: any, + private readonly presenter = new RecurringProformaReadinessPresenter() + ) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + const parsed = UniqueID.create(this.req.params.recurring_proforma_id); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (parsed.isFailure) return this.invalidInputError(parsed.error.message); + const result = await this.readiness({ companyId, id: parsed.data }); + return result.match( + (data: any) => this.ok(this.presenter.present(data)), + (error: Error) => this.handleError(error) + ); + } +} +class Transition extends RecurringProformaController { + public constructor(private readonly command: any) { + super(); + } + protected async executeImpl() { + const companyId = this.company(); + const parsed = UniqueID.create(this.req.params.recurring_proforma_id); + if (!companyId) return this.forbiddenError("Tenant ID not found"); + if (parsed.isFailure) return this.invalidInputError(parsed.error.message); + const result = await this.command({ companyId, id: parsed.data }); + return result.match( + (data: any) => this.ok(data), + (error: Error) => this.handleError(error) + ); + } +} +export class ActivateRecurringProformaController extends Transition {} +export class PauseRecurringProformaController extends Transition {} +export class ResumeRecurringProformaController extends Transition {} +export class CompleteRecurringProformaController extends Transition {} +export class ArchiveRecurringProformaController extends Transition {} +export class DuplicateRecurringProformaController extends Transition {} +export class DeleteRecurringProformaController extends Transition {} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts new file mode 100644 index 00000000..88625b3b --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/controllers/update-recurring-proforma.controller.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { UpdateRecurringProformaRequestSchema } from "../../../../../common"; + +test("PATCH conserva omitted, null y value para payment_term_id", () => { + assert.equal(UpdateRecurringProformaRequestSchema.parse({ description: "Nueva" }).payment_term_id, undefined); + assert.equal(UpdateRecurringProformaRequestSchema.parse({ payment_term_id: null }).payment_term_id, null); + assert.equal(UpdateRecurringProformaRequestSchema.parse({ payment_term_id: "01900000-0000-7000-8000-000000000901" }).payment_term_id, "01900000-0000-7000-8000-000000000901"); +}); + +test("PATCH conserva omitted, null y value para language", () => { + assert.equal(UpdateRecurringProformaRequestSchema.parse({}).language, undefined); + assert.equal(UpdateRecurringProformaRequestSchema.parse({ language: null }).language, null); + assert.equal(UpdateRecurringProformaRequestSchema.parse({ language: "es" }).language, "es"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.test.ts new file mode 100644 index 00000000..629e0327 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseDateOnly } from "./parse-date-only"; + +test("parseDateOnly convierte una fecha de calendario a medianoche UTC", () => { + const result = parseDateOnly("2026-09-01"); + + assert.equal(result.isSuccess, true); + assert.equal(result.data instanceof Date, true); + assert.equal(result.data.getUTCFullYear(), 2026); + assert.equal(result.data.getUTCMonth(), 8); + assert.equal(result.data.getUTCDate(), 1); +}); + +test("parseDateOnly acepta el 29 de febrero de un ano bisiesto", () => { + const result = parseDateOnly("2024-02-29"); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.toISOString().slice(0, 10), "2024-02-29"); +}); + +test("parseDateOnly rechaza una fecha de calendario imposible", () => { + const result = parseDateOnly("2026-02-30"); + + assert.equal(result.isFailure, true); +}); + +test("parseDateOnly conserva el dia de calendario en el round-trip UTC", () => { + const result = parseDateOnly("2026-09-01"); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.toISOString().slice(0, 10), "2026-09-01"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.ts new file mode 100644 index 00000000..55c634ed --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/mappers/parse-date-only.ts @@ -0,0 +1,6 @@ +import { UtcDate } from "@repo/rdx-ddd"; +import type { Result } from "@repo/rdx-utils"; + +/** Convierte el date-only HTTP a la representación UTC que usa Application. */ +export const parseDateOnly = (value: string): Result => + UtcDate.create(value).map((date) => date.toDate()); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts new file mode 100644 index 00000000..040bbf99 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/index.ts @@ -0,0 +1,84 @@ +const dateOnly = (value: Date | null) => (value ? value.toISOString().slice(0, 10) : null); +const id = (value: { toPrimitive(): string } | null) => value?.toPrimitive() ?? null; +export class RecurringProformaListPresenter { + public present(page: any) { + return { + page: page.page, + per_page: page.perPage, + total_items: page.totalItems, + total_pages: page.totalPages, + items: page.items.map((item: any) => ({ + id: id(item.id), + name: item.name, + customer_id: id(item.customerId), + status: item.status, + schedule: item.schedule + ? { + start_date: dateOnly(item.schedule.startDate), + end_date: dateOnly(item.schedule.endDate), + unit: item.schedule.unit, + interval: item.schedule.interval, + monthly_rule: item.schedule.monthlyRule, + } + : null, + next_occurrence_date: dateOnly(item.nextOccurrenceDate), + updated_at: item.updatedAt.toISOString(), + })), + }; + } +} +export class RecurringProformaDetailPresenter { + public present(item: any) { + return { + id: id(item.id), + name: item.name, + status: item.status, + customer_id: id(item.customerId), + description: item.description, + payment_term_id: id(item.paymentTermId), + payment_method_id: id(item.paymentMethodId), + language: item.language, + currency: item.currency, + proforma_series: item.proformaSeries, + issued_invoice_series: item.issuedInvoiceSeries, + global_discount_percentage: item.globalDiscountPercentage?.toObjectString() ?? null, + schedule: item.schedule + ? { + start_date: dateOnly(item.schedule.startDate), + end_date: dateOnly(item.schedule.endDate), + unit: item.schedule.unit, + interval: item.schedule.interval, + monthly_rule: item.schedule.monthlyRule, + } + : null, + next_occurrence_date: dateOnly(item.nextOccurrenceDate), + items: item.items, + created_at: item.createdAt.toISOString(), + updated_at: item.updatedAt.toISOString(), + }; + } +} +export class RecurringProformaOccurrencePresenter { + public present(page: any) { + return { + page: page.page, + per_page: page.perPage, + total_items: page.totalItems, + total_pages: page.totalPages, + items: page.items.map((item: any) => ({ + id: id(item.id), + scheduled_date: dateOnly(item.scheduledDate), + status: item.status, + attempt_count: item.attemptCount, + last_attempt_at: item.lastAttemptAt?.toISOString() ?? null, + processing_started_at: item.processingStartedAt?.toISOString() ?? null, + last_error_code: item.lastErrorCode, + last_error_message: item.lastErrorMessage, + proforma_id: id(item.proformaId), + generated_at: item.generatedAt?.toISOString() ?? null, + created_at: item.createdAt.toISOString(), + updated_at: item.updatedAt.toISOString(), + })), + }; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.test.ts new file mode 100644 index 00000000..74830b00 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RecurringProformaReadinessPresenter } from "./recurring-proforma-readiness.presenter"; + +test("presenta readiness bloqueada sin reinterpretar ni duplicar codes", () => { + assert.deepEqual( + new RecurringProformaReadinessPresenter().present({ + canActivate: false, + blockers: [{ code: "missing_customer" }, { code: "missing_schedule" }], + }), + { can_activate: false, blockers: [{ code: "missing_customer" }, { code: "missing_schedule" }] } + ); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.ts new file mode 100644 index 00000000..7f090d5f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proforma-readiness.presenter.ts @@ -0,0 +1,8 @@ +export class RecurringProformaReadinessPresenter { + public present(value: { canActivate: boolean; blockers: readonly { code: string }[] }) { + return { + can_activate: value.canActivate, + blockers: value.blockers.map((blocker) => ({ code: blocker.code })), + }; + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts new file mode 100644 index 00000000..3c25cc58 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.http.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; + +import { + RecurringProformaDetailPresenter, + RecurringProformaListPresenter, + RecurringProformaOccurrencePresenter, +} from "./index"; + +test("HTTP list presenta paginación vacía y fila con schedule date-only", () => { + const presenter = new RecurringProformaListPresenter(); + assert.deepEqual( + presenter.present({ page: 3, perPage: 17, totalItems: 0, totalPages: 0, items: [] }), + { page: 3, per_page: 17, total_items: 0, total_pages: 0, items: [] } + ); + const dto = presenter.present({ + page: 3, + perPage: 17, + totalItems: 1, + totalPages: 1, + items: [ + { + id: UniqueID.generateNewID(), + name: "Mantenimiento", + customerId: UniqueID.generateNewID(), + status: "active", + schedule: { + startDate: new Date("2027-02-03T00:00:00.000Z"), + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }, + nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"), + updatedAt: new Date("2027-02-01T10:00:00.000Z"), + }, + ], + }); + assert.equal(dto.items[0]?.schedule.start_date, "2027-02-03"); + assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-03"); +}); + +test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => { + const base = { + id: UniqueID.generateNewID(), + name: "Cuota", + status: "draft", + customerId: null, + description: null, + globalDiscountPercentage: DiscountPercentage.create({ value: 500 }).data, + schedule: { + startDate: new Date("2027-01-31T00:00:00.000Z"), + endDate: null, + unit: "month", + interval: 1, + monthlyRule: "same_day", + }, + nextOccurrenceDate: new Date("2027-02-28T00:00:00.000Z"), + items: [{ description: "A" }, { description: "B" }], + createdAt: new Date(), + updatedAt: new Date(), + }; + const nulls = new RecurringProformaDetailPresenter().present({ + ...base, + paymentTermId: null, + paymentMethodId: null, + language: null, + currency: null, + proformaSeries: null, + issuedInvoiceSeries: null, + }); + assert.deepEqual( + [ + nulls.payment_term_id, + nulls.payment_method_id, + nulls.language, + nulls.currency, + nulls.proforma_series, + nulls.issued_invoice_series, + ], + [null, null, null, null, null, null] + ); + assert.equal(nulls.schedule.start_date, "2027-01-31"); + assert.equal(nulls.schedule.end_date, null); + assert.equal(nulls.next_occurrence_date, "2027-02-28"); + assert.deepEqual( + nulls.items.map((item: { description: string }) => item.description), + ["A", "B"] + ); + const override = new RecurringProformaDetailPresenter().present({ + ...base, + paymentTermId: UniqueID.generateNewID(), + paymentMethodId: null, + language: "es", + currency: null, + proformaSeries: "RP", + issuedInvoiceSeries: null, + }); + assert.equal(override.language, "es"); + assert.equal(override.proforma_series, "RP"); + assert.notEqual(override.payment_term_id, null); +}); + +test("HTTP occurrences distingue scheduled_date de timestamps y conserva generated/failed", () => { + const proformaId = UniqueID.generateNewID(); + const dto = new RecurringProformaOccurrencePresenter().present({ + page: 1, + perPage: 10, + totalItems: 2, + totalPages: 1, + items: [ + { + id: UniqueID.generateNewID(), + scheduledDate: new Date("2027-04-05T00:00:00.000Z"), + status: "generated", + attemptCount: 1, + lastAttemptAt: new Date("2027-04-05T10:00:00.000Z"), + processingStartedAt: null, + lastErrorCode: null, + lastErrorMessage: null, + proformaId, + generatedAt: new Date("2027-04-05T10:01:00.000Z"), + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: UniqueID.generateNewID(), + scheduledDate: new Date("2027-04-04T00:00:00.000Z"), + status: "failed", + attemptCount: 2, + lastAttemptAt: new Date("2027-04-04T10:00:00.000Z"), + processingStartedAt: null, + lastErrorCode: "MATERIALIZE_FAILED", + lastErrorMessage: "Error", + proformaId: null, + generatedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + assert.equal(dto.items[0]?.scheduled_date, "2027-04-05"); + assert.match(dto.items[0]?.generated_at ?? "", /T/); + assert.equal(dto.items[0]?.proforma_id, proformaId.toPrimitive()); + assert.equal(dto.items[1]?.attempt_count, 2); + assert.equal(dto.items[1]?.last_error_code, "MATERIALIZE_FAILED"); + assert.equal(dto.items[1]?.proforma_id, null); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts new file mode 100644 index 00000000..751d61a4 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/presenters/recurring-proformas-read.presenters.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; + +import { RecurringProformaDetailPresenter } from "./index"; + +test("RecurringProformaDetailPresenter conserva PercentageSchema como strings value y scale", () => { + const discount = DiscountPercentage.create({ value: 500 }).data; + const dto = new RecurringProformaDetailPresenter().present({ + id: UniqueID.generateNewID(), + name: "Cuota", + status: "draft", + customerId: null, + description: null, + paymentTermId: null, + paymentMethodId: null, + language: null, + currency: null, + proformaSeries: null, + issuedInvoiceSeries: null, + globalDiscountPercentage: discount, + schedule: null, + nextOccurrenceDate: null, + items: [], + createdAt: new Date("2027-01-01T10:00:00.000Z"), + updatedAt: new Date("2027-01-01T10:00:00.000Z"), + }); + assert.deepEqual(dto.global_discount_percentage, { value: "500", scale: "2" }); + assert.equal(typeof dto.global_discount_percentage.value, "string"); + assert.equal(typeof dto.global_discount_percentage.scale, "string"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts new file mode 100644 index 00000000..1563431c --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/express/recurring-proformas.routes.ts @@ -0,0 +1,122 @@ +import { type StartParams, validateRequest } from "@erp/core/api"; +import { requireIdentityTenant } from "@erp/identity/api"; +import { type NextFunction, type Request, type Response, Router } from "express"; + +import { + CreateRecurringProformaRequestSchema, + ListRecurringProformaOccurrencesRequestSchema, + ListRecurringProformasRequestSchema, + RecurringProformaIdParamsSchema, + UpdateRecurringProformaRequestSchema, +} from "../../../../common"; +import type { ProformasInternalDeps } from "../../proformas/di/proformas.di"; + +import { + ActivateRecurringProformaController, + ArchiveRecurringProformaController, + CompleteRecurringProformaController, + CreateRecurringProformaController, + DeleteRecurringProformaController, + DuplicateRecurringProformaController, + GetRecurringProformaActivationReadinessController, + GetRecurringProformaController, + ListRecurringProformaOccurrencesController, + ListRecurringProformasController, + PauseRecurringProformaController, + ResumeRecurringProformaController, + UpdateRecurringProformaController, +} from "./controllers"; + +export const recurringProformasRouter = (params: StartParams) => { + const deps = params.getInternal( + "customer-invoices", + "proformas" + ).recurringProformas; + const router = Router({ mergeParams: true }); + router.use(...requireIdentityTenant(params)); + router.get( + "/", + validateRequest(ListRecurringProformasRequestSchema, "query"), + (req: Request, res: Response, next: NextFunction) => + new ListRecurringProformasController(deps.list).execute(req, res, next) + ); + router.post( + "/", + validateRequest(CreateRecurringProformaRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => + new CreateRecurringProformaController(deps.create).execute(req, res, next) + ); + router.patch( + "/:recurring_proforma_id", + validateRequest(RecurringProformaIdParamsSchema, "params"), + validateRequest(UpdateRecurringProformaRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => + new UpdateRecurringProformaController(deps.update).execute(req, res, next) + ); + router.get( + "/:recurring_proforma_id/activation-readiness", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new GetRecurringProformaActivationReadinessController(deps.activationReadiness).execute( + req, + res, + next + ) + ); + router.post( + "/:recurring_proforma_id/activate", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new ActivateRecurringProformaController(deps.activate).execute(req, res, next) + ); + router.post( + "/:recurring_proforma_id/pause", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new PauseRecurringProformaController(deps.pause).execute(req, res, next) + ); + router.post( + "/:recurring_proforma_id/resume", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new ResumeRecurringProformaController(deps.resume).execute(req, res, next) + ); + router.post( + "/:recurring_proforma_id/complete", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new CompleteRecurringProformaController(deps.complete).execute(req, res, next) + ); + router.post( + "/:recurring_proforma_id/archive", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new ArchiveRecurringProformaController(deps.archive).execute(req, res, next) + ); + router.post( + "/:recurring_proforma_id/duplicate", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new DuplicateRecurringProformaController(deps.duplicate).execute(req, res, next) + ); + router.delete( + "/:recurring_proforma_id", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new DeleteRecurringProformaController(deps.delete).execute(req, res, next) + ); + router.get( + "/:recurring_proforma_id/occurrences", + validateRequest(RecurringProformaIdParamsSchema, "params"), + validateRequest(ListRecurringProformaOccurrencesRequestSchema, "query"), + (req: Request, res: Response, next: NextFunction) => + new ListRecurringProformaOccurrencesController(deps.occurrences).execute(req, res, next) + ); + router.get( + "/:recurring_proforma_id", + validateRequest(RecurringProformaIdParamsSchema, "params"), + (req: Request, res: Response, next: NextFunction) => + new GetRecurringProformaController(deps.detail).execute(req, res, next) + ); + params.app.use(`${params.config.server.apiBasePath}/recurring-proformas`, router); +}; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/index.ts new file mode 100644 index 00000000..a8ef57d4 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/index.ts @@ -0,0 +1,2 @@ +export * from "./persistence"; +export * from "./express/recurring-proformas.routes"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/index.ts new file mode 100644 index 00000000..62f8ac11 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/index.ts @@ -0,0 +1 @@ +export * from "./sequelize"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/index.ts new file mode 100644 index 00000000..bad13e0e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/index.ts @@ -0,0 +1,2 @@ +export * from "./mappers"; +export * from "./repositories"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/index.ts new file mode 100644 index 00000000..bf956bd4 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/index.ts @@ -0,0 +1,2 @@ +export * from "./recurring-proforma-domain.mapper"; +export * from "./recurring-proforma-occurrence-domain.mapper"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.test.ts new file mode 100644 index 00000000..d7da5665 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { InvoiceSerie, RecurringProforma } from "../../../../../domain"; +import type { RecurringProformaModel } from "../../../../common"; + +import { SequelizeRecurringProformaDomainMapper } from "./recurring-proforma-domain.mapper"; + +const required = (result: { data: T }): T => result.data; + +test("SequelizeRecurringProformaDomainMapper persiste draft mínimo con schedule y overrides nulos", () => { + const source = required( + RecurringProforma.create({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000301")), + name: "Cuota", + }) + ); + const persistence = required(new SequelizeRecurringProformaDomainMapper().mapToPersistence(source)); + + assert.equal(persistence.schedule_start_date, null); + assert.equal(persistence.next_occurrence_date, null); + assert.equal(persistence.payment_term_id, null); + assert.equal(persistence.currency, null); + assert.equal(persistence.description, null); +}); + +test("SequelizeRecurringProformaDomainMapper preserva description entre dominio y persistencia", () => { + const source = required( + RecurringProforma.create({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000301")), + name: "Cuota", + description: Maybe.some("Servicio comercial"), + }, required(UniqueID.create("01900000-0000-7000-8000-000000000302"))) + ); + const persistence = required(new SequelizeRecurringProformaDomainMapper().mapToPersistence(source)); + const rehydrated = required( + new SequelizeRecurringProformaDomainMapper().mapToDomain({ + ...persistence, + items: [], + } as unknown as RecurringProformaModel) + ); + + assert.equal(persistence.description, "Servicio comercial"); + assert.equal(rehydrated.description.unwrap(), "Servicio comercial"); + assert.equal(rehydrated.createdAt.toISOString(), source.createdAt.toISOString()); + assert.equal(rehydrated.updatedAt.toISOString(), source.updatedAt.toISOString()); +}); + +test("SequelizeRecurringProformaDomainMapper rechaza schedule parcialmente persistido", () => { + const raw = { + id: "01900000-0000-7000-8000-000000000302", + company_id: "01900000-0000-7000-8000-000000000301", + name: "Corrupta", + description: null, + global_discount_percentage_value: null, + global_discount_percentage_scale: null, + status: "draft", + customer_id: null, + payment_term_id: null, + payment_method_id: null, + language: null, + currency: null, + proforma_series_code: null, + issued_invoice_series_code: null, + schedule_start_date: "2027-01-15", + schedule_end_date: null, + schedule_unit: null, + schedule_interval: null, + schedule_monthly_rule: null, + next_occurrence_date: null, + created_at: new Date("2027-01-01T00:00:00.000Z"), + updated_at: new Date("2027-01-01T00:00:00.000Z"), + items: [], + } as unknown as RecurringProformaModel; + + assert.equal(new SequelizeRecurringProformaDomainMapper().mapToDomain(raw).isFailure, true); +}); + +test("SequelizeRecurringProformaDomainMapper rechaza descuento global parcialmente persistido", () => { + const base = { + id: "01900000-0000-7000-8000-000000000302", + company_id: "01900000-0000-7000-8000-000000000301", + name: "Corrupta", + description: null, + global_discount_percentage_value: null, + global_discount_percentage_scale: null, + status: "draft", + customer_id: null, + payment_term_id: null, + payment_method_id: null, + language: null, + currency: null, + proforma_series_code: null, + issued_invoice_series_code: null, + schedule_start_date: null, + schedule_end_date: null, + schedule_unit: null, + schedule_interval: null, + schedule_monthly_rule: null, + next_occurrence_date: null, + created_at: new Date("2027-01-01T00:00:00.000Z"), + updated_at: new Date("2027-01-01T00:00:00.000Z"), + items: [], + }; + const mapper = new SequelizeRecurringProformaDomainMapper(); + + assert.equal( + mapper.mapToDomain({ ...base, global_discount_percentage_value: 15, global_discount_percentage_scale: null } as unknown as RecurringProformaModel).isFailure, + true + ); + assert.equal( + mapper.mapToDomain({ ...base, global_discount_percentage_value: null, global_discount_percentage_scale: 2 } as unknown as RecurringProformaModel).isFailure, + true + ); +}); + +test("SequelizeRecurringProformaDomainMapper conserva preferencias de series por código", () => { + const source = required(RecurringProforma.create({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000301")), + name: "Cuota", + proformaSeries: Maybe.some(required(InvoiceSerie.create("PRO"))), + issuedInvoiceSeries: Maybe.some(required(InvoiceSerie.create("INV"))), + })); + const mapper = new SequelizeRecurringProformaDomainMapper(); + const persistence = required(mapper.mapToPersistence(source)); + const rehydrated = required(mapper.mapToDomain({ ...persistence, items: [] } as unknown as RecurringProformaModel)); + + assert.equal(persistence.proforma_series_code, "PRO"); + assert.equal(persistence.issued_invoice_series_code, "INV"); + assert.equal(rehydrated.proformaSeries.unwrap().toPrimitive(), "PRO"); + assert.equal(rehydrated.issuedInvoiceSeries.unwrap().toPrimitive(), "INV"); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts new file mode 100644 index 00000000..28df924f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-domain.mapper.ts @@ -0,0 +1,278 @@ +import { DiscountPercentage } from "@erp/core/api"; +import { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + InvoiceSerie, + ItemAmount, + ItemDescription, + ItemQuantity, + RecurrenceSchedule, + RecurringProforma, + type RecurringProformaInternalProps, + RecurringProformaItem, + RecurringProformaItems, +} from "../../../../../domain"; +import type { RecurringProformaItemModel, RecurringProformaModel } from "../../../../common"; + +const toDate = (value: string | Date): Date => + value instanceof Date ? new Date(value.getTime()) : new Date(`${value}T00:00:00.000Z`); + +const toDateOnly = (value: Date): string => value.toISOString().slice(0, 10); + +const nullable = (value: Maybe, map: (item: T) => U): U | null => + value.isSome() ? map(value.unwrap()) : null; + +const orDefault = (value: Maybe, map: (item: T) => U, fallback: U): U => + value.isSome() ? map(value.unwrap()) : fallback; + +export class SequelizeRecurringProformaDomainMapper { + public mapToDomain(raw: RecurringProformaModel): Result { + const scheduleResult = this.mapSchedule(raw); + if (scheduleResult.isFailure) return Result.fail(scheduleResult.error); + + const itemsResult = this.mapItems(raw.items ?? []); + if (itemsResult.isFailure) return Result.fail(itemsResult.error); + + const companyId = UniqueID.create(raw.company_id); + const id = UniqueID.create(raw.id); + const customerId = this.mapOptionalId(raw.customer_id); + const paymentTermId = this.mapOptionalId(raw.payment_term_id); + const paymentMethodId = this.mapOptionalId(raw.payment_method_id); + const proformaSeries = this.mapOptionalSeries(raw.proforma_series_code); + const issuedInvoiceSeries = this.mapOptionalSeries(raw.issued_invoice_series_code); + const language = this.mapOptionalLanguage(raw.language); + const currency = this.mapOptionalCurrency(raw.currency); + const globalDiscount = + raw.global_discount_percentage_value === null && raw.global_discount_percentage_scale === null + ? Result.ok(Maybe.none()) + : raw.global_discount_percentage_value === null || + raw.global_discount_percentage_scale === null + ? Result.fail( + new Error("Recurring proforma global discount columns are partially populated") + ) + : DiscountPercentage.create({ + value: raw.global_discount_percentage_value, + scale: raw.global_discount_percentage_scale, + }).map(Maybe.some); + + const results = [ + companyId, + id, + customerId, + paymentTermId, + paymentMethodId, + proformaSeries, + issuedInvoiceSeries, + language, + currency, + globalDiscount, + ]; + const failure = results.find((result) => result.isFailure); + if (failure?.isFailure) return Result.fail(failure.error); + + const props: RecurringProformaInternalProps = { + companyId: companyId.data, + name: raw.name, + description: raw.description === null ? Maybe.none() : Maybe.some(raw.description), + globalDiscountPercentage: globalDiscount.data, + customerId: customerId.data, + schedule: scheduleResult.data, + status: raw.status as RecurringProformaInternalProps["status"], + nextOccurrenceDate: + raw.next_occurrence_date === null + ? Maybe.none() + : Maybe.some(toDate(raw.next_occurrence_date)), + paymentTermId: paymentTermId.data, + paymentMethodId: paymentMethodId.data, + language: language.data, + currency: currency.data, + proformaSeries: proformaSeries.data, + issuedInvoiceSeries: issuedInvoiceSeries.data, + createdAt: new Date(raw.created_at), + updatedAt: new Date(raw.updated_at), + }; + + return RecurringProforma.rehydrate(props, itemsResult.data, id.data); + } + + public mapToPersistence(source: RecurringProforma): Result, Error> { + const schedule = source.schedule.map((value) => value.getProps()); + + return Result.ok({ + id: source.id.toPrimitive(), + company_id: source.companyId.toPrimitive(), + name: source.name, + description: nullable(source.description, (value) => value), + global_discount_percentage_value: nullable( + source.globalDiscountPercentage, + (value) => value.toPrimitive().value + ), + global_discount_percentage_scale: nullable( + source.globalDiscountPercentage, + (value) => value.toPrimitive().scale + ), + status: source.status, + customer_id: nullable(source.customerId, (value) => value.toPrimitive()), + payment_term_id: nullable(source.paymentTermId, (value) => value.toPrimitive()), + payment_method_id: nullable(source.paymentMethodId, (value) => value.toPrimitive()), + language: nullable(source.language, (value) => value.toPrimitive()), + currency: nullable(source.currency, (value) => value.toPrimitive()), + proforma_series_code: nullable(source.proformaSeries, (value) => value.toPrimitive()), + issued_invoice_series_code: nullable(source.issuedInvoiceSeries, (value) => + value.toPrimitive() + ), + schedule_start_date: nullable(schedule, (value) => toDateOnly(value.startDate)), + schedule_end_date: nullable(schedule, (value) => + value.endDate ? toDateOnly(value.endDate) : null + ), + schedule_unit: nullable(schedule, (value) => value.unit), + schedule_interval: nullable(schedule, (value) => value.interval), + schedule_monthly_rule: nullable(schedule, (value) => value.monthlyRule), + next_occurrence_date: nullable(source.nextOccurrenceDate, toDateOnly), + created_at: source.createdAt, + updated_at: source.updatedAt, + }); + } + + public mapItemsToPersistence( + source: RecurringProforma + ): Result[], Error> { + return Result.ok( + source.items.getAll().map((item, position) => ({ + id: item.id.toPrimitive(), + recurring_proforma_id: source.id.toPrimitive(), + description: nullable(item.description, (value) => value.toPrimitive()), + quantity_value: nullable(item.quantity, (value) => value.toPrimitive().value), + quantity_scale: orDefault( + item.quantity, + (value) => value.toPrimitive().scale, + ItemQuantity.DEFAULT_SCALE + ), + unit_amount_value: nullable(item.unitAmount, (value) => value.toPrimitive().value), + unit_amount_scale: orDefault( + item.unitAmount, + (value) => value.toPrimitive().scale, + ItemAmount.DEFAULT_SCALE + ), + unit_amount_currency: nullable( + item.unitAmount, + (value) => value.toPrimitive().currency_code + ), + item_discount_percentage_value: nullable( + item.itemDiscountPercentage, + (value) => value.toPrimitive().value + ), + item_discount_percentage_scale: orDefault( + item.itemDiscountPercentage, + (value) => value.toPrimitive().scale, + DiscountPercentage.DEFAULT_SCALE + ), + iva_code: nullable(item.ivaCode, (value) => value), + rec_code: nullable(item.recCode, (value) => value), + retention_code: nullable(item.retentionCode, (value) => value), + position, + })) + ); + } + + private mapSchedule(raw: RecurringProformaModel): Result, Error> { + const hasRequired = [raw.schedule_start_date, raw.schedule_unit, raw.schedule_interval].filter( + (value) => value !== null + ).length; + if (hasRequired === 0 && raw.schedule_end_date === null && raw.schedule_monthly_rule === null) + return Result.ok(Maybe.none()); + if (hasRequired !== 3) + return Result.fail(new Error("Recurring proforma schedule columns are partially populated")); + const result = RecurrenceSchedule.create({ + startDate: toDate(raw.schedule_start_date!), + endDate: raw.schedule_end_date === null ? null : toDate(raw.schedule_end_date), + unit: raw.schedule_unit as "week" | "month" | "year", + interval: raw.schedule_interval!, + monthlyRule: raw.schedule_monthly_rule as "same_day" | "last_day_of_month" | null, + }); + return result.isFailure ? Result.fail(result.error) : Result.ok(Maybe.some(result.data)); + } + + private mapItems(rawItems: RecurringProformaItemModel[]): Result { + const mapped: RecurringProformaItem[] = []; + for (const raw of [...rawItems].sort((left, right) => left.position - right.position)) { + const id = UniqueID.create(raw.id); + + const description = + raw.description === null + ? Result.ok(Maybe.none()) + : ItemDescription.create(raw.description).map(Maybe.some); + + const quantity = + raw.quantity_value === null + ? Result.ok(Maybe.none()) + : ItemQuantity.create({ value: raw.quantity_value, scale: raw.quantity_scale }).map( + Maybe.some + ); + + const amount = + raw.unit_amount_value === null && raw.unit_amount_currency === null + ? Result.ok(Maybe.none()) + : raw.unit_amount_value === null || raw.unit_amount_currency === null + ? Result.fail( + new Error("Recurring proforma item amount columns are partially populated") + ) + : ItemAmount.create({ + value: raw.unit_amount_value, + scale: raw.unit_amount_scale, + currency_code: raw.unit_amount_currency, + }).map(Maybe.some); + + const discount = + raw.item_discount_percentage_value === null + ? Result.ok(Maybe.none()) + : DiscountPercentage.create({ + value: raw.item_discount_percentage_value, + scale: raw.item_discount_percentage_scale, + }).map(Maybe.some); + const results = [id, description, quantity, amount, discount]; + const failure = results.find((result) => result.isFailure); + if (failure?.isFailure) return Result.fail(failure.error); + mapped.push( + RecurringProformaItem.rehydrate( + { + description: description.data, + quantity: quantity.data, + unitAmount: amount.data, + itemDiscountPercentage: discount.data, + ivaCode: raw.iva_code === null ? Maybe.none() : Maybe.some(raw.iva_code), + recCode: raw.rec_code === null ? Maybe.none() : Maybe.some(raw.rec_code), + retentionCode: + raw.retention_code === null ? Maybe.none() : Maybe.some(raw.retention_code), + }, + id.data + ) + ); + } + return Result.ok(RecurringProformaItems.create({ items: mapped })); + } + + private mapOptionalId(value: string | null): Result, Error> { + if (value === null) return Result.ok(Maybe.none()); + return UniqueID.create(value).map(Maybe.some); + } + + private mapOptionalSeries(value: string | null): Result, Error> { + if (value === null) { + return Result.ok>(Maybe.none()); + } + + return InvoiceSerie.create(value).map>(Maybe.some); + } + + private mapOptionalLanguage(value: string | null): Result, Error> { + if (value === null) return Result.ok(Maybe.none()); + return LanguageCode.create(value).map(Maybe.some); + } + + private mapOptionalCurrency(value: string | null): Result, Error> { + if (value === null) return Result.ok(Maybe.none()); + return CurrencyCode.create(value).map(Maybe.some); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.test.ts new file mode 100644 index 00000000..3280df15 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { RecurringProformaOccurrenceModel } from "../../../../common"; + +import { SequelizeRecurringProformaOccurrenceDomainMapper } from "./recurring-proforma-occurrence-domain.mapper"; + +test("SequelizeRecurringProformaOccurrenceDomainMapper rechaza occurrence generated corrupta", () => { + const raw = { + id: "01900000-0000-7000-8000-000000000401", + company_id: "01900000-0000-7000-8000-000000000402", + recurring_proforma_id: "01900000-0000-7000-8000-000000000403", + scheduled_date: "2027-01-15", + status: "generated", + attempt_count: 1, + last_attempt_at: new Date("2027-01-15T08:00:00.000Z"), + processing_started_at: null, + last_error_code: null, + last_error_message: null, + proforma_id: null, + generated_at: null, + created_at: new Date("2027-01-01T00:00:00.000Z"), + updated_at: new Date("2027-01-15T08:00:00.000Z"), + } as unknown as RecurringProformaOccurrenceModel; + + assert.equal(new SequelizeRecurringProformaOccurrenceDomainMapper().mapToDomain(raw).isFailure, true); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts new file mode 100644 index 00000000..e485ec1f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/mappers/recurring-proforma-occurrence-domain.mapper.ts @@ -0,0 +1,75 @@ +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { RecurringProformaOccurrence } from "../../../../../domain"; +import type { RecurringProformaOccurrenceModel } from "../../../../common"; + +const dateOnlyToDate = (value: string | Date): Date => + value instanceof Date ? new Date(value.getTime()) : new Date(`${value}T00:00:00.000Z`); + +const nullable = (value: Maybe, map: (item: T) => U): U | null => + value.isSome() ? map(value.unwrap()) : null; + +export class SequelizeRecurringProformaOccurrenceDomainMapper { + public mapToDomain( + raw: RecurringProformaOccurrenceModel + ): Result { + const ids = [ + UniqueID.create(raw.id), + UniqueID.create(raw.company_id), + UniqueID.create(raw.recurring_proforma_id), + raw.proforma_id === null + ? Result.ok(Maybe.none()) + : UniqueID.create(raw.proforma_id).map(Maybe.some), + ]; + const failure = ids.find((result) => result.isFailure); + if (failure?.isFailure) return Result.fail(failure.error); + + return RecurringProformaOccurrence.rehydrate( + { + companyId: ids[1].data, + recurringProformaId: ids[2].data, + scheduledDate: dateOnlyToDate(raw.scheduled_date), + status: raw.status as "pending" | "processing" | "failed" | "generated", + attemptCount: raw.attempt_count, + lastAttemptAt: + raw.last_attempt_at === null ? Maybe.none() : Maybe.some(new Date(raw.last_attempt_at)), + processingStartedAt: + raw.processing_started_at === null + ? Maybe.none() + : Maybe.some(new Date(raw.processing_started_at)), + lastErrorCode: + raw.last_error_code === null ? Maybe.none() : Maybe.some(raw.last_error_code), + lastErrorMessage: + raw.last_error_message === null ? Maybe.none() : Maybe.some(raw.last_error_message), + proformaId: ids[3].data, + generatedAt: + raw.generated_at === null ? Maybe.none() : Maybe.some(new Date(raw.generated_at)), + createdAt: new Date(raw.created_at), + updatedAt: new Date(raw.updated_at), + }, + ids[0].data + ); + } + + public mapToPersistence( + source: RecurringProformaOccurrence + ): Result, Error> { + return Result.ok({ + id: source.id.toPrimitive(), + company_id: source.companyId.toPrimitive(), + recurring_proforma_id: source.recurringProformaId.toPrimitive(), + scheduled_date: source.scheduledDate.toISOString().slice(0, 10), + status: source.status, + attempt_count: source.attemptCount, + last_attempt_at: nullable(source.lastAttemptAt, (value) => value), + processing_started_at: nullable(source.processingStartedAt, (value) => value), + last_error_code: nullable(source.lastErrorCode, (value) => value), + last_error_message: nullable(source.lastErrorMessage, (value) => value), + proforma_id: nullable(source.proformaId, (value) => value.toPrimitive()), + generated_at: nullable(source.generatedAt, (value) => value), + created_at: source.createdAt, + updated_at: source.updatedAt, + }); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/index.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/index.ts new file mode 100644 index 00000000..3fed07ae --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/index.ts @@ -0,0 +1,2 @@ +export * from "./sequelize-recurring-proforma.repository"; +export * from "./sequelize-recurring-proforma-occurrence.repository"; diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts new file mode 100644 index 00000000..576359b7 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { RecurringProformaOccurrence } from "../../../../../domain"; +import { RecurringProformaOccurrenceModel } from "../../../../common"; + +import { SequelizeRecurringProformaOccurrenceRepository } from "./sequelize-recurring-proforma-occurrence.repository"; + +const required = (result: { data: T }): T => result.data; + +const createOccurrence = () => + required( + RecurringProformaOccurrence.create({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000601")), + recurringProformaId: required( + UniqueID.create("01900000-0000-7000-8000-000000000602") + ), + scheduledDate: new Date("2027-01-15T00:00:00.000Z"), + createdAt: new Date("2027-01-01T00:00:00.000Z"), + updatedAt: new Date("2027-01-01T00:00:00.000Z"), + }, required(UniqueID.create("01900000-0000-7000-8000-000000000603"))) + ); + +test("SequelizeRecurringProformaOccurrenceRepository guarda el payload de occurrence", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalUpsert = RecurringProformaOccurrenceModel.upsert; + let payload: unknown; + RecurringProformaOccurrenceModel.upsert = (async (value: unknown) => { + payload = value; + return [{} as never, true]; + }) as typeof RecurringProformaOccurrenceModel.upsert; + + try { + const result = await repository.save(occurrence); + assert.equal(result.isSuccess, true); + assert.deepEqual(payload, { + id: occurrence.id.toPrimitive(), + company_id: occurrence.companyId.toPrimitive(), + recurring_proforma_id: occurrence.recurringProformaId.toPrimitive(), + scheduled_date: "2027-01-15", + status: "pending", + attempt_count: 0, + last_attempt_at: null, + processing_started_at: null, + last_error_code: null, + last_error_message: null, + proforma_id: null, + generated_at: null, + created_at: occurrence.createdAt, + updated_at: occurrence.updatedAt, + }); + } finally { + RecurringProformaOccurrenceModel.upsert = originalUpsert; + } +}); + +test("SequelizeRecurringProformaOccurrenceRepository filtra findByIdInCompany por tenant", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalFindOne = RecurringProformaOccurrenceModel.findOne; + let receivedWhere: unknown; + RecurringProformaOccurrenceModel.findOne = (async (options?: { where?: unknown }) => { + receivedWhere = options?.where; + return null; + }) as typeof RecurringProformaOccurrenceModel.findOne; + + try { + const result = await repository.findByIdInCompany(occurrence.companyId, occurrence.id); + assert.equal(result.isFailure, true); + assert.deepEqual(receivedWhere, { + id: occurrence.id.toPrimitive(), + company_id: occurrence.companyId.toPrimitive(), + }); + } finally { + RecurringProformaOccurrenceModel.findOne = originalFindOne; + } +}); + +test("findByRecurringProformaAndScheduledDate limita recurrence, fecha y company", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalFindOne = RecurringProformaOccurrenceModel.findOne; + let receivedWhere: unknown; + RecurringProformaOccurrenceModel.findOne = (async (options?: { where?: unknown }) => { + receivedWhere = options?.where; + return null; + }) as typeof RecurringProformaOccurrenceModel.findOne; + try { + const result = await repository.findByRecurringProformaAndScheduledDate( + occurrence.recurringProformaId, + occurrence.scheduledDate, + occurrence.companyId + ); + assert.equal(result.data.isNone(), true); + assert.deepEqual(receivedWhere, { + recurring_proforma_id: occurrence.recurringProformaId.toPrimitive(), + company_id: occurrence.companyId.toPrimitive(), + scheduled_date: "2027-01-15", + }); + } finally { + RecurringProformaOccurrenceModel.findOne = originalFindOne; + } +}); + +test("latestScheduledDate usa MAX y limita la consulta por recurrence y company", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalFindOne = RecurringProformaOccurrenceModel.findOne; + let options: unknown; + RecurringProformaOccurrenceModel.findOne = (async (value: unknown) => { + options = value; + return { latest_scheduled_date: "2027-02-15" } as never; + }) as typeof RecurringProformaOccurrenceModel.findOne; + + try { + const result = await repository.latestScheduledDate( + occurrence.recurringProformaId, + occurrence.companyId + ); + assert.equal(result.data.unwrap().toISOString().slice(0, 10), "2027-02-15"); + assert.deepEqual((options as { where: unknown }).where, { + recurring_proforma_id: occurrence.recurringProformaId.toPrimitive(), + company_id: occurrence.companyId.toPrimitive(), + }); + } finally { + RecurringProformaOccurrenceModel.findOne = originalFindOne; + } +}); + +test("hasFailedOccurrences limita status failed por recurrence y company", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalCount = RecurringProformaOccurrenceModel.count; + let receivedWhere: unknown; + RecurringProformaOccurrenceModel.count = (async (options?: { where?: unknown }) => { + receivedWhere = options?.where; + return 1; + }) as typeof RecurringProformaOccurrenceModel.count; + + try { + assert.equal( + (await repository.hasFailedOccurrences(occurrence.recurringProformaId, occurrence.companyId)).data, + true + ); + assert.deepEqual(receivedWhere, { + recurring_proforma_id: occurrence.recurringProformaId.toPrimitive(), + company_id: occurrence.companyId.toPrimitive(), + status: "failed", + }); + } finally { + RecurringProformaOccurrenceModel.count = originalCount; + } +}); + +test("listByRecurringProformaInCompany limita tenant, parent y orden determinista", async () => { + const repository = new SequelizeRecurringProformaOccurrenceRepository(); + const occurrence = createOccurrence(); + const originalFindAndCountAll = RecurringProformaOccurrenceModel.findAndCountAll; + let options: { where?: unknown; order?: unknown; limit?: number; offset?: number } | undefined; + RecurringProformaOccurrenceModel.findAndCountAll = (async (value: typeof options) => { + options = value; + return { count: 0, rows: [] } as never; + }) as typeof RecurringProformaOccurrenceModel.findAndCountAll; + try { + const result = await repository.listByRecurringProformaInCompany({ companyId: occurrence.companyId, recurringProformaId: occurrence.recurringProformaId, page: 3, limit: 5 }); + assert.equal(result.isSuccess, true); + assert.deepEqual(options?.where, { company_id: occurrence.companyId.toPrimitive(), recurring_proforma_id: occurrence.recurringProformaId.toPrimitive() }); + assert.deepEqual(options?.order, [["scheduled_date", "DESC"], ["id", "DESC"]]); + assert.equal(options?.limit, 5); + assert.equal(options?.offset, 10); + } finally { + RecurringProformaOccurrenceModel.findAndCountAll = originalFindAndCountAll; + } +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts new file mode 100644 index 00000000..9589071c --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma-occurrence.repository.ts @@ -0,0 +1,188 @@ +import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { type Transaction, col, fn } from "sequelize"; + +import type { + IRecurringProformaOccurrenceRepository, + ListRecurringProformaOccurrencesQuery, + RecurringProformaOccurrenceReadModel, + RecurringProformaPage, +} from "../../../../../application"; +import type { RecurringProformaOccurrence } from "../../../../../domain"; +import { RecurringProformaOccurrenceModel } from "../../../../common"; +import { SequelizeRecurringProformaOccurrenceDomainMapper } from "../mappers"; + +export class SequelizeRecurringProformaOccurrenceRepository + implements IRecurringProformaOccurrenceRepository +{ + private readonly mapper = new SequelizeRecurringProformaOccurrenceDomainMapper(); + + public async save( + occurrence: RecurringProformaOccurrence, + transaction?: unknown + ): Promise> { + try { + const persistence = this.mapper.mapToPersistence(occurrence); + if (persistence.isFailure) return Result.fail(persistence.error); + await RecurringProformaOccurrenceModel.upsert(persistence.data as any, { + transaction: transaction as Transaction | undefined, + }); + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise> { + try { + const row = await RecurringProformaOccurrenceModel.findOne({ + where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() }, + transaction: transaction as Transaction | undefined, + }); + if (!row) { + return Result.fail( + new EntityNotFoundError("RecurringProformaOccurrence", "id", id.toString()) + ); + } + return this.mapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByRecurringProformaAndScheduledDate( + recurringProformaId: UniqueID, + scheduledDate: Date, + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>> { + try { + const row = await RecurringProformaOccurrenceModel.findOne({ + where: { + recurring_proforma_id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + scheduled_date: scheduledDate.toISOString().slice(0, 10), + }, + transaction: transaction as Transaction | undefined, + }); + if (!row) return Result.ok(Maybe.none()); + const occurrence = this.mapper.mapToDomain(row); + return occurrence.isFailure + ? Result.fail(occurrence.error) + : Result.ok(Maybe.some(occurrence.data)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async latestScheduledDate( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>> { + try { + const row = await RecurringProformaOccurrenceModel.findOne({ + attributes: [[fn("MAX", col("scheduled_date")), "latest_scheduled_date"]], + where: { + recurring_proforma_id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + }, + raw: true, + transaction: transaction as Transaction | undefined, + }); + const value = (row as unknown as { latest_scheduled_date?: string | null } | null) + ?.latest_scheduled_date; + return Result.ok(value ? Maybe.some(new Date(`${value}T00:00:00.000Z`)) : Maybe.none()); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async hasFailedOccurrences( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise> { + try { + const count = await RecurringProformaOccurrenceModel.count({ + where: { + recurring_proforma_id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + status: "failed", + }, + transaction: transaction as Transaction | undefined, + }); + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async listByRecurringProformaInCompany( + query: ListRecurringProformaOccurrencesQuery, + transaction?: unknown + ): Promise, Error>> { + try { + const limit = Math.max(1, query.limit); + const page = Math.max(1, query.page); + const { count, rows } = await RecurringProformaOccurrenceModel.findAndCountAll({ + where: { + company_id: query.companyId.toPrimitive(), + recurring_proforma_id: query.recurringProformaId.toPrimitive(), + }, + order: [ + ["scheduled_date", "DESC"], + ["id", "DESC"], + ], + limit, + offset: (page - 1) * limit, + transaction: transaction as Transaction | undefined, + }); + const items: RecurringProformaOccurrenceReadModel[] = []; + for (const row of rows) { + const id = UniqueID.create(row.id); + if (id.isFailure) return Result.fail(id.error); + const proformaId = row.proforma_id ? UniqueID.create(row.proforma_id) : null; + if (proformaId?.isFailure) return Result.fail(proformaId.error); + items.push({ + id: id.data, + scheduledDate: this.toDateOnly(row.scheduled_date), + status: row.status as RecurringProformaOccurrenceReadModel["status"], + attemptCount: row.attempt_count, + lastAttemptAt: row.last_attempt_at, + processingStartedAt: row.processing_started_at, + lastErrorCode: row.last_error_code, + lastErrorMessage: row.last_error_message, + proformaId: proformaId?.data ?? null, + generatedAt: row.generated_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } + return Result.ok({ + page, + perPage: limit, + totalItems: count, + totalPages: Math.ceil(count / limit), + items, + }); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + private toDateOnly(value: string | Date | null | undefined): Date { + if (value === null || value === undefined) { + throw new Error("Recurring proforma occurrence date projection is missing"); + } + if (value instanceof Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); + } + return new Date(`${value}T00:00:00.000Z`); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts new file mode 100644 index 00000000..53697d20 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Op, Sequelize } from "sequelize"; + +import { RecurringProforma } from "../../../../../domain"; +import { + RecurringProformaItemModel, + RecurringProformaModel, + RecurringProformaOccurrenceModel, +} from "../../../../common"; + +import { SequelizeRecurringProformaRepository } from "./sequelize-recurring-proforma.repository"; + +const required = (result: { data: T }): T => result.data; + +test("SequelizeRecurringProformaRepository mantiene company_id en hasOccurrences", async () => { + const repository = new SequelizeRecurringProformaRepository( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const originalCount = RecurringProformaOccurrenceModel.count; + let receivedWhere: unknown; + RecurringProformaOccurrenceModel.count = (async (options?: { where?: unknown }) => { + receivedWhere = options?.where; + return 0; + }) as typeof RecurringProformaOccurrenceModel.count; + + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const recurringProformaId = required(UniqueID.create("01900000-0000-7000-8000-000000000502")); + try { + const result = await repository.hasOccurrences(recurringProformaId, companyId); + assert.equal(result.data, false); + assert.deepEqual(receivedWhere, { + recurring_proforma_id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + }); + } finally { + RecurringProformaOccurrenceModel.count = originalCount; + } +}); + +test("SequelizeRecurringProformaRepository reemplaza items en la transacción suministrada", async () => { + const repository = new SequelizeRecurringProformaRepository( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const recurringProforma = required( + RecurringProforma.create({ + companyId: required(UniqueID.create("01900000-0000-7000-8000-000000000501")), + name: "Cuota", + }) + ); + const originalUpsert = RecurringProformaModel.upsert; + const originalDestroy = RecurringProformaItemModel.destroy; + RecurringProformaModel.upsert = (async () => [{} as never, true]) as typeof RecurringProformaModel.upsert; + RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy; + try { + assert.equal((await repository.save(recurringProforma, {})).isSuccess, true); + } finally { + RecurringProformaModel.upsert = originalUpsert; + RecurringProformaItemModel.destroy = originalDestroy; + } +}); + +test("findByIdInCompanyForUpdate exige transacción y limita por company", async () => { + const repository = new SequelizeRecurringProformaRepository( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const recurringProformaId = required(UniqueID.create("01900000-0000-7000-8000-000000000502")); + const transaction = {}; + const originalFindOne = RecurringProformaModel.findOne; + let options: { where?: unknown; lock?: unknown; transaction?: unknown } | undefined; + RecurringProformaModel.findOne = (async (value: typeof options) => { + options = value; + return null; + }) as typeof RecurringProformaModel.findOne; + try { + assert.equal((await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction)).isFailure, true); + assert.equal(options?.lock, true); + assert.equal(options?.transaction, transaction); + assert.deepEqual(options?.where, { id: recurringProformaId.toPrimitive(), company_id: companyId.toPrimitive() }); + } finally { + RecurringProformaModel.findOne = originalFindOne; + } +}); + +test("listInCompany limita tenant, filtros, paginación y orden determinista", async () => { + const repository = new SequelizeRecurringProformaRepository( + new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false }) + ); + const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501")); + const originalFindAndCountAll = RecurringProformaModel.findAndCountAll; + let options: { where?: unknown; order?: unknown; limit?: number; offset?: number } | undefined; + RecurringProformaModel.findAndCountAll = (async (value: typeof options) => { + options = value; + return { count: 0, rows: [] } as never; + }) as typeof RecurringProformaModel.findAndCountAll; + try { + const result = await repository.listInCompany({ companyId, status: "draft", search: "Cuota", page: 2, limit: 10 }); + assert.equal(result.isSuccess, true); + assert.deepEqual(options?.where, { company_id: companyId.toPrimitive(), status: "draft", name: { [Op.like]: "%Cuota%" } }); + assert.deepEqual(options?.order, [["updated_at", "DESC"], ["id", "DESC"]]); + assert.equal(options?.limit, 10); + assert.equal(options?.offset, 10); + } finally { + RecurringProformaModel.findAndCountAll = originalFindAndCountAll; + } +}); diff --git a/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts new file mode 100644 index 00000000..1af9bb04 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/recurring-proformas/persistence/sequelize/repositories/sequelize-recurring-proforma.repository.ts @@ -0,0 +1,249 @@ +import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { Op, type Transaction } from "sequelize"; + +import type { + DueRecurringProforma, + IRecurringProformaRepository, + ListRecurringProformasQuery, + RecurringProformaListItemReadModel, + RecurringProformaPage, +} from "../../../../../application"; +import type { RecurringProforma } from "../../../../../domain"; +import { + RecurringProformaItemModel, + RecurringProformaModel, + RecurringProformaOccurrenceModel, +} from "../../../../common"; +import { SequelizeRecurringProformaDomainMapper } from "../mappers"; + +export class SequelizeRecurringProformaRepository implements IRecurringProformaRepository { + private readonly mapper = new SequelizeRecurringProformaDomainMapper(); + + public async save( + recurringProforma: RecurringProforma, + transaction?: unknown + ): Promise> { + try { + const persistence = this.mapper.mapToPersistence(recurringProforma); + const items = this.mapper.mapItemsToPersistence(recurringProforma); + if (persistence.isFailure) return Result.fail(persistence.error); + if (items.isFailure) return Result.fail(items.error); + + const sqlTransaction = transaction as Transaction | undefined; + await RecurringProformaModel.upsert(persistence.data as any, { transaction: sqlTransaction }); + await RecurringProformaItemModel.destroy({ + where: { recurring_proforma_id: recurringProforma.id.toPrimitive() }, + transaction: sqlTransaction, + }); + if (items.data.length > 0) { + await RecurringProformaItemModel.bulkCreate(items.data as any[], { + transaction: sqlTransaction, + }); + } + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise> { + try { + const row = await RecurringProformaModel.findOne({ + where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() }, + include: [{ model: RecurringProformaItemModel, as: "items", required: false }], + order: [[{ model: RecurringProformaItemModel, as: "items" }, "position", "ASC"]], + transaction: transaction as Transaction | undefined, + }); + if (!row) + return Result.fail(new EntityNotFoundError("RecurringProforma", "id", id.toString())); + return this.mapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findByIdInCompanyForUpdate( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise> { + try { + const row = await RecurringProformaModel.findOne({ + where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() }, + include: [{ model: RecurringProformaItemModel, as: "items", required: false }], + order: [[{ model: RecurringProformaItemModel, as: "items" }, "position", "ASC"]], + transaction: transaction as Transaction, + lock: true, + }); + if (!row) + return Result.fail(new EntityNotFoundError("RecurringProforma", "id", id.toString())); + return this.mapper.mapToDomain(row); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async deleteByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise> { + try { + const deleted = await RecurringProformaModel.destroy({ + where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() }, + transaction: transaction as Transaction | undefined, + }); + return Result.ok(deleted > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async hasOccurrences( + recurringProformaId: UniqueID, + companyId: UniqueID, + transaction?: unknown + ): Promise> { + try { + const count = await RecurringProformaOccurrenceModel.count({ + where: { + recurring_proforma_id: recurringProformaId.toPrimitive(), + company_id: companyId.toPrimitive(), + }, + transaction: transaction as Transaction | undefined, + }); + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findDue( + dueThrough: Date, + limit: number + ): Promise> { + try { + const rows = await RecurringProformaModel.findAll({ + attributes: ["id", "company_id", "next_occurrence_date"], + where: { + status: "active", + next_occurrence_date: { [Op.lte]: dueThrough.toISOString().slice(0, 10), [Op.ne]: null }, + }, + order: [ + ["next_occurrence_date", "ASC"], + ["id", "ASC"], + ], + limit, + }); + const due: DueRecurringProforma[] = []; + for (const row of rows) { + const recurringProformaId = UniqueID.create(row.id); + const companyId = UniqueID.create(row.company_id); + if (recurringProformaId.isFailure) return Result.fail(recurringProformaId.error); + if (companyId.isFailure) return Result.fail(companyId.error); + due.push({ + recurringProformaId: recurringProformaId.data, + companyId: companyId.data, + nextOccurrenceDate: new Date(`${row.next_occurrence_date}T00:00:00.000Z`), + }); + } + return Result.ok(due); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async listInCompany( + query: ListRecurringProformasQuery, + transaction?: unknown + ): Promise, Error>> { + try { + const where = { + company_id: query.companyId.toPrimitive(), + ...(query.status ? { status: query.status } : {}), + ...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}), + }; + const limit = Math.max(1, query.limit); + const page = Math.max(1, query.page); + const { count, rows } = await RecurringProformaModel.findAndCountAll({ + attributes: [ + "id", + "name", + "customer_id", + "status", + "schedule_start_date", + "schedule_end_date", + "schedule_unit", + "schedule_interval", + "schedule_monthly_rule", + "next_occurrence_date", + "updated_at", + ], + where, + order: [ + ["updated_at", "DESC"], + ["id", "DESC"], + ], + limit, + offset: (page - 1) * limit, + transaction: transaction as Transaction | undefined, + }); + const items: RecurringProformaListItemReadModel[] = []; + for (const row of rows) { + const id = UniqueID.create(row.id); + if (id.isFailure) return Result.fail(id.error); + const customerId = row.customer_id ? UniqueID.create(row.customer_id) : null; + if (customerId?.isFailure) return Result.fail(customerId.error); + const hasSchedule = row.schedule_start_date !== null; + items.push({ + id: id.data, + name: row.name, + customerId: customerId?.data ?? null, + status: row.status as RecurringProformaListItemReadModel["status"], + schedule: hasSchedule + ? { + startDate: this.toDateOnly(row.schedule_start_date), + endDate: row.schedule_end_date ? this.toDateOnly(row.schedule_end_date) : null, + unit: row.schedule_unit as NonNullable< + RecurringProformaListItemReadModel["schedule"] + >["unit"], + interval: row.schedule_interval ?? 0, + monthlyRule: row.schedule_monthly_rule as NonNullable< + RecurringProformaListItemReadModel["schedule"] + >["monthlyRule"], + } + : null, + nextOccurrenceDate: row.next_occurrence_date + ? this.toDateOnly(row.next_occurrence_date) + : null, + updatedAt: row.updated_at, + }); + } + return Result.ok({ + page, + perPage: limit, + totalItems: count, + totalPages: Math.ceil(count / limit), + items, + }); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + private toDateOnly(value: string | Date | null | undefined): Date { + if (value === null || value === undefined) { + throw new Error("Recurring proforma date projection is missing"); + } + if (value instanceof Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate())); + } + return new Date(`${value}T00:00:00.000Z`); + } +} diff --git a/modules/customer-invoices/src/api/module.ts b/modules/customer-invoices/src/api/module.ts new file mode 100644 index 00000000..1e3c47f3 --- /dev/null +++ b/modules/customer-invoices/src/api/module.ts @@ -0,0 +1,34 @@ +import type { IModuleServer } from "@erp/core/api"; + +import { + buildCompanyVerifactuProfileDependencies, + buildIssuedInvoicePublicServices, + buildIssuedInvoicesDependencies, + buildProformaPublicServices, + buildProformasDependencies, + models, +} from "./infrastructure"; + +/** Manifest neutral: compone capacidades del módulo sin adapters HTTP. */ +export const customerInvoicesModule: IModuleServer = { + name: "customer-invoices", + version: "1.0.0", + dependencies: ["catalogs", "customers", "identity", "companies", "document-series"], + async setup(params) { + const companyVerifactuProfileInternal = buildCompanyVerifactuProfileDependencies(params); + const issuedInvoicesInternal = buildIssuedInvoicesDependencies(params); + const proformasInternal = buildProformasDependencies(params); + return { + models, + services: { + issuedInvoices: buildIssuedInvoicePublicServices(params, issuedInvoicesInternal), + proformas: buildProformaPublicServices(params, proformasInternal), + }, + internal: { + companyVerifactuProfile: companyVerifactuProfileInternal, + issuedInvoices: issuedInvoicesInternal, + proformas: proformasInternal, + }, + }; + }, +}; diff --git a/modules/customer-invoices/src/common/dto/request/index.ts b/modules/customer-invoices/src/common/dto/request/index.ts index f59eb142..64694712 100644 --- a/modules/customer-invoices/src/common/dto/request/index.ts +++ b/modules/customer-invoices/src/common/dto/request/index.ts @@ -1,3 +1,4 @@ export * from "./issued-invoices"; export * from "./proformas"; +export * from "./recurring-proformas"; export * from "./settings"; diff --git a/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.test.ts b/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.test.ts new file mode 100644 index 00000000..6060a6de --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CreateRecurringProformaFromProformaRequestSchema } from "./create-recurring-proforma-from-proforma.request.dto"; + +const validBody = { + name: "Mantenimiento mensual", + schedule: { + startDate: "2026-09-01", + endDate: "2026-12-31", + unit: "month" as const, + interval: 1, + monthlyRule: "same_day" as const, + }, +}; + +test("CreateRecurringProformaFromProformaRequestSchema rechaza interval cero", () => { + const fromProformaCalls: unknown[] = []; + const result = CreateRecurringProformaFromProformaRequestSchema.safeParse({ + ...validBody, + schedule: { ...validBody.schedule, interval: 0 }, + }); + + assert.equal(result.success, false); + assert.equal(fromProformaCalls.length, 0); +}); + +test("CreateRecurringProformaFromProformaRequestSchema rechaza campos comerciales copiados", () => { + const result = CreateRecurringProformaFromProformaRequestSchema.safeParse({ + ...validBody, + customerId: "01900000-0000-7000-8000-000000000001", + }); + + assert.equal(result.success, false); +}); diff --git a/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.ts b/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.ts new file mode 100644 index 00000000..5a47522e --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/recurring-proformas/create-recurring-proforma-from-proforma.request.dto.ts @@ -0,0 +1,20 @@ +import { z } from "zod/v4"; + +export const CreateRecurringProformaFromProformaRequestSchema = z + .object({ + name: z.string().trim().min(1), + schedule: z + .object({ + startDate: z.iso.date(), + endDate: z.iso.date().nullable().optional(), + unit: z.enum(["week", "month", "year"]), + interval: z.number().int().positive(), + monthlyRule: z.enum(["same_day", "last_day_of_month"]).nullable(), + }) + .strict(), + }) + .strict(); + +export type CreateRecurringProformaFromProformaRequestDTO = z.infer< + typeof CreateRecurringProformaFromProformaRequestSchema +>; diff --git a/modules/customer-invoices/src/common/dto/request/recurring-proformas/index.ts b/modules/customer-invoices/src/common/dto/request/recurring-proformas/index.ts new file mode 100644 index 00000000..b9178f24 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/recurring-proformas/index.ts @@ -0,0 +1,2 @@ +export * from "./recurring-proformas-read.request.dto"; +export * from "./create-recurring-proforma-from-proforma.request.dto"; diff --git a/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts b/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts new file mode 100644 index 00000000..4f6218a1 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/recurring-proformas/recurring-proformas-read.request.dto.ts @@ -0,0 +1,24 @@ +import { z } from "zod/v4"; + +export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() }); +export const ListRecurringProformasRequestSchema = z.object({ + search: z.string().min(1).optional(), + status: z.enum(["draft", "active", "paused", "completed", "archived"]).optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); +export const ListRecurringProformaOccurrencesRequestSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); +export const CreateRecurringProformaRequestSchema = z.object({ + name: z.string().trim().min(1), + description: z.string().nullable().optional(), +}); +export const UpdateRecurringProformaRequestSchema = z.object({ + name: z.string().trim().min(1).optional(), + description: z.string().nullable().optional(), + payment_term_id: z.uuid().nullable().optional(), + language: z.string().nullable().optional(), + proforma_series: z.string().nullable().optional(), +}); diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 4bac7658..d62bd742 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -21,6 +21,121 @@ "search": "Search", "clear": "Clear" }, + "recurring_proformas": { + "list": { + "title": "Recurring proformas", + "description": "Manage your recurring proformas", + "create": "New recurring proforma", + "search_placeholder": "Search...", + "status_filter": "Filter by status", + "empty": "There are no recurring proformas.", + "columns": { + "name": "Name", + "status": "Status", + "schedule": "Recurrence", + "updated_at": "Updated" + }, + "schedule": { + "none": "No recurrence", + "week_one": "Every week", + "week_other": "Every {{count}} weeks", + "month_one": "Every month", + "month_other": "Every {{count}} months", + "year_one": "Every year", + "year_other": "Every {{count}} years", + "same_day": "Same day", + "last_day_of_month": "Last day of the month" + }, + "error": { + "title": "The list could not be loaded", + "description": "An error occurred while loading recurring proformas.", + "retry": "Retry" + } + }, + "status": { + "all": "All", + "draft": "Draft", + "active": "Active", + "paused": "Paused", + "completed": "Completed", + "archived": "Archived" + }, + "detail": { + "context": "Recurring proforma", + "back": "Back to list", + "loading": "Loading recurring proforma", + "customer": "Customer", + "no_customer": "No customer", + "created_at": "Created", + "updated_at": "Updated", + "inherited": "Inherited", + "general": { + "title": "General information" + }, + "schedule": { + "title": "Schedule", + "interval": "Recurrence", + "start_date": "Start date", + "end_date": "End date", + "no_end_date": "No end date", + "monthly_rule": "Monthly rule", + "not_applicable": "Not applicable" + }, + "commercial": { + "title": "Commercial conditions", + "payment_term": "Payment term", + "payment_method": "Payment method", + "language": "Language", + "currency": "Currency", + "proforma_series": "Proforma series", + "issued_invoice_series": "Issued invoice series", + "global_discount": "Global discount" + }, + "items": { + "title": "Items", + "empty": "There are no configured items.", + "no_description": "No description" + }, + "error": { + "title": "The recurring proforma could not be loaded", + "description": "An error occurred while loading the detail.", + "retry": "Retry" + } + }, + "occurrences": { + "title": "Generations", + "empty": "There are no generations for this recurrence yet.", + "error": "The generations could not be loaded.", + "retry": "Retry loading", + "open_proforma": "View proforma", + "columns": { + "scheduled_date": "Scheduled date", + "status": "Status", + "attempt_count": "Attempts", + "proforma": "Proforma", + "generated_at": "Generated at", + "last_error": "Last error" + }, + "status": { + "pending": "Pending", + "processing": "Processing", + "failed": "Failed", + "generated": "Generated" + } + }, + "create": { + "title": "New recurring proforma", + "description": "Create a minimal draft to complete its configuration later.", + "form_title": "Initial details", + "name": "Name", + "name_required": "Name is required.", + "description_label": "Description", + "submit": "Create", + "creating": "Creating...", + "cancel": "Cancel", + "error": "The recurring proforma could not be created." + } + }, "catalog": { "proformas": { "status": { @@ -510,6 +625,16 @@ } }, "filters": { + "date": { + "label": "Filter by proforma date", + "options": { + "last7Days": "Last 7 days", + "currentMonth": "Current month", + "last3Months": "Last 3 months", + "currentYear": "Current year", + "all": "All time" + } + }, "status": { "label": "Status" } diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 8f52257d..9a351530 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -21,6 +21,121 @@ "search": "Buscar", "clear": "Limpiar" }, + "recurring_proformas": { + "list": { + "title": "Proformas recurrentes", + "description": "Gestiona tus proformas recurrentes", + "create": "Nueva proforma recurrente", + "search_placeholder": "Buscar...", + "status_filter": "Filtrar por estado", + "empty": "No hay proformas recurrentes.", + "columns": { + "name": "Nombre", + "status": "Estado", + "schedule": "Recurrencia", + "updated_at": "Actualizada" + }, + "schedule": { + "none": "Sin recurrencia", + "week_one": "Cada semana", + "week_other": "Cada {{count}} semanas", + "month_one": "Cada mes", + "month_other": "Cada {{count}} meses", + "year_one": "Cada ano", + "year_other": "Cada {{count}} anos", + "same_day": "Mismo dia", + "last_day_of_month": "Ultimo dia del mes" + }, + "error": { + "title": "No se pudo cargar el listado", + "description": "Ha ocurrido un error al cargar las proformas recurrentes.", + "retry": "Reintentar" + } + }, + "status": { + "all": "Todos", + "draft": "Borrador", + "active": "Activa", + "paused": "Pausada", + "completed": "Completada", + "archived": "Archivada" + }, + "detail": { + "context": "Proforma recurrente", + "back": "Volver al listado", + "loading": "Cargando proforma recurrente", + "customer": "Cliente", + "no_customer": "Sin cliente", + "created_at": "Creada", + "updated_at": "Actualizada", + "inherited": "Heredado", + "general": { + "title": "Informacion general" + }, + "schedule": { + "title": "Programacion", + "interval": "Recurrencia", + "start_date": "Fecha de inicio", + "end_date": "Fecha de fin", + "no_end_date": "Sin fecha de fin", + "monthly_rule": "Regla mensual", + "not_applicable": "No aplica" + }, + "commercial": { + "title": "Condiciones comerciales", + "payment_term": "Plazo de pago", + "payment_method": "Metodo de pago", + "language": "Idioma", + "currency": "Moneda", + "proforma_series": "Serie de proforma", + "issued_invoice_series": "Serie de factura emitida", + "global_discount": "Descuento global" + }, + "items": { + "title": "Lineas", + "empty": "No hay lineas configuradas.", + "no_description": "Sin descripcion" + }, + "error": { + "title": "No se pudo cargar la proforma recurrente", + "description": "Ha ocurrido un error al cargar el detalle.", + "retry": "Reintentar" + } + }, + "occurrences": { + "title": "Generaciones", + "empty": "Todavia no hay generaciones para esta recurrencia.", + "error": "No se pudieron cargar las generaciones.", + "retry": "Reintentar carga", + "open_proforma": "Ver proforma", + "columns": { + "scheduled_date": "Fecha prevista", + "status": "Estado", + "attempt_count": "Intentos", + "proforma": "Proforma", + "generated_at": "Generada el", + "last_error": "Ultimo error" + }, + "status": { + "pending": "Pendiente", + "processing": "Procesando", + "failed": "Fallida", + "generated": "Generada" + } + }, + "create": { + "title": "Nueva proforma recurrente", + "description": "Crea un borrador minimo para completar su configuracion mas adelante.", + "form_title": "Datos iniciales", + "name": "Nombre", + "name_required": "El nombre es obligatorio.", + "description_label": "Descripcion", + "submit": "Crear", + "creating": "Creando...", + "cancel": "Cancelar", + "error": "No se pudo crear la proforma recurrente." + } + }, "catalog": { "proformas": { "status": { @@ -510,6 +625,16 @@ } }, "filters": { + "date": { + "label": "Filtrar por fecha de proforma", + "options": { + "last7Days": "Últimos 7 días", + "currentMonth": "Mes actual", + "last3Months": "Últimos 3 meses", + "currentYear": "Año actual", + "all": "Todas" + } + }, "status": { "label": "Estado" } diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index f4400295..7d946a50 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -30,6 +30,18 @@ const ProformaDetailPage = lazy(() => import("./proformas/detail").then((m) => ({ default: m.ProformaDetailPage })) ); +const RecurringProformasListPage = lazy(() => + import("./recurring-proformas").then((m) => ({ default: m.ListRecurringProformasPage })) +); + +const RecurringProformaCreatePage = lazy(() => + import("./recurring-proformas").then((m) => ({ default: m.RecurringProformaCreatePage })) +); + +const RecurringProformaDetailPage = lazy(() => + import("./recurring-proformas").then((m) => ({ default: m.RecurringProformaDetailPage })) +); + const IssuedInvoicesLayout = lazy(() => import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout })) ); @@ -117,6 +129,30 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] ), }, + { + path: "recurring-proformas", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, + { + path: "recurring-proformas/create", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, + { + path: "recurring-proformas/:recurring_proforma_id", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, { path: "customer-invoices", diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index 6ab6b89b..ef5032fe 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -10,14 +10,20 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { + EMPTY_PROFORMA_STATUS_COUNTS, type ListProformasByCriteriaParams, type ProformaList, type ProformaStatus, - EMPTY_PROFORMA_STATUS_COUNTS, useProformasListQuery, } from "../../shared"; -import type { ProformaListScope, ProformaListStatusFilter } from "../types/proforma-list-filters"; +import { + DEFAULT_PROFORMA_DATE_PRESET, + type ProformaDatePreset, + type ProformaListScope, + type ProformaListStatusFilter, +} from "../types/proforma-list-filters"; import { buildListProformasCriteria } from "../utils/build-list-proformas-criteria"; +import { resolveProformaDatePreset } from "../utils/resolve-proforma-date-preset"; // Datos por defecto mientras se carga la consulta o en caso de error. const EMPTY_PROFORMAS_LIST: ProformaList = { @@ -60,6 +66,7 @@ export const useListProformasController = (scope: ProformaListScope) => { ((searchParams.get("status") as ProformaStatus | "all" | null) ?? "all") as ProformaListStatusFilter ); + const datePreset = resolveProformaDatePreset(searchParams.get("date")); const tablePreferences = useDataTablePreferences({ storageKey: "proformas:list:grid", @@ -129,8 +136,9 @@ export const useListProformasController = (scope: ProformaListScope) => { pageSize, q: debouncedSearch || "", status: statusFilter, + datePreset, }), - [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] + [datePreset, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] ); const query = useProformasListQuery({ criteria, scope }); @@ -203,6 +211,28 @@ export const useListProformasController = (scope: ProformaListScope) => { [pageSize, setSearchParams, setPageSizePreference] ); + const setDatePresetValue = useCallback( + (value: ProformaDatePreset) => { + if (datePreset === value) { + return; + } + + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + + if (value === DEFAULT_PROFORMA_DATE_PRESET && !prev.has("date")) { + params.delete("date"); + } else { + params.set("date", value); + } + + return params; + }); + }, + [datePreset, setSearchParams] + ); + const setSortValue = useCallback( (nextSort: DataTableSort) => { if (!isProformaListSortField(nextSort.field)) { @@ -254,5 +284,7 @@ export const useListProformasController = (scope: ProformaListScope) => { setSort: setSortValue, statusFilter, setStatusFilter: setStatusFilterValue, + datePreset, + setDatePreset: setDatePresetValue, }; }; diff --git a/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts b/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts index 9f379548..881d73fe 100644 --- a/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts +++ b/modules/customer-invoices/src/web/proformas/list/types/proforma-list-filters.ts @@ -3,3 +3,12 @@ import type { ProformaStatus } from "../../shared"; export type ProformaListScope = "normal" | "archived" | "deleted"; export type ProformaListStatusFilter = "all" | ProformaStatus; + +export type ProformaDatePreset = + | "last7Days" + | "currentMonth" + | "last3Months" + | "currentYear" + | "all"; + +export const DEFAULT_PROFORMA_DATE_PRESET: ProformaDatePreset = "currentYear"; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts b/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts index 83b4bcec..c2787741 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/index.ts @@ -1,4 +1,5 @@ export * from "./archive-proforma-dialog"; +export * from "./proforma-date-preset-filter"; export * from "./proforma-status-badge"; -export * from "./unarchive-proforma-dialog"; export * from "./proforma-status-segmented-filter"; +export * from "./unarchive-proforma-dialog"; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-date-preset-filter.tsx b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-date-preset-filter.tsx new file mode 100644 index 00000000..2f1cb321 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/ui/components/proforma-date-preset-filter.tsx @@ -0,0 +1,76 @@ +import { Select, SelectContent, SelectItem, SelectTrigger } from "@repo/shadcn-ui/components"; +import { CalendarIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import type { ProformaDatePreset } from "../../types/proforma-list-filters"; + +export interface ProformaDatePresetFilterProps { + value: ProformaDatePreset; + onValueChange: (value: ProformaDatePreset) => void; + disabled?: boolean; +} + +const PROFORMA_DATE_PRESET_OPTIONS = [ + { + value: "last7Days", + labelKey: "pages.proformas.list.filters.date.options.last7Days", + }, + { + value: "currentMonth", + labelKey: "pages.proformas.list.filters.date.options.currentMonth", + }, + { + value: "last3Months", + labelKey: "pages.proformas.list.filters.date.options.last3Months", + }, + { + value: "currentYear", + labelKey: "pages.proformas.list.filters.date.options.currentYear", + }, + { + value: "all", + labelKey: "pages.proformas.list.filters.date.options.all", + }, +] as const satisfies ReadonlyArray<{ + value: ProformaDatePreset; + labelKey: string; +}>; + +export const ProformaDatePresetFilter = ({ + value, + onValueChange, + disabled = false, +}: ProformaDatePresetFilterProps) => { + const { t } = useTranslation(); + const selectedOption = PROFORMA_DATE_PRESET_OPTIONS.find((option) => option.value === value); + const selectedLabel = t( + selectedOption?.labelKey ?? "pages.proformas.list.filters.date.options.currentYear" + ); + + return ( +
+ +
+ ); +}; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 5c98a896..e7b3dddb 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -4,8 +4,6 @@ import { type DataTableMeta, RdxPageContainer } from "@repo/rdx-ui/components"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { Button, - Card, - CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, @@ -16,14 +14,11 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup, + Separator, Spinner, } from "@repo/shadcn-ui/components"; import { - CheckCircle2Icon, - CircleDollarSignIcon, - ClockIcon, FileDownIcon, - FileTextIcon, MoreHorizontalIcon, PlusIcon, RefreshCwIcon, @@ -46,6 +41,7 @@ import type { ProformaListScope } from "../../types/proforma-list-filters"; import { ProformaSummaryPanel, ProformasGrid, useProformasGridColumns } from "../blocks"; import { ArchiveProformaDialog, + ProformaDatePresetFilter, ProformaStatusBadge, ProformaStatusSegmentedFilter, UnarchiveProformaDialog, @@ -363,13 +359,21 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { topContent={
-
- +
+ +
+ +
@@ -474,77 +478,6 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { - {/* Stats Cards */} -
- - -
-
- -
-
-

Total proformas

-

1.248

-

- ↑ 12.5% vs. mes anterior -

-
-
-
-
- - - -
-
- -
-
-

Importe total

-

125.430,75 €

-

- ↑ 8.1% vs. mes anterior -

-
-
-
-
- - - -
-
- -
-
-

Pendientes

-

356

-

- ↑ 5.2% vs. mes anterior -

-
-
-
-
- - - -
-
- -
-
-

Convertidas

-

892

-

- ↑ 15.7% vs. mes anterior -

-
-
-
-
-
- {/* Table */} {scopeConfig.enablePanel && isPanelOpen ? ( { + const criteria = buildListProformasCriteria({ + datePreset: "currentYear", + order: "desc", + orderBy: "proforma_date", + pageNumber: 0, + pageSize: 25, + q: "", + status: "all", + today: "2026-08-29", + }); + + assert.deepEqual(criteria.filters, [ + { + field: "proforma_date", + operator: "GREATER_THAN_OR_EQUAL", + value: "2026-01-01", + }, + { + field: "proforma_date", + operator: "LOWER_THAN_OR_EQUAL", + value: "2026-08-29", + }, + ]); +}); + +test("combina status con rango de fecha sin perder búsqueda ni orden", () => { + const criteria = buildListProformasCriteria({ + datePreset: "currentMonth", + order: "asc", + orderBy: "recipient_name", + pageNumber: 2, + pageSize: 10, + q: "acme", + status: "approved", + today: "2026-08-29", + }); + + assert.equal(criteria.q, "acme"); + assert.equal(criteria.orderBy, "recipient_name"); + assert.equal(criteria.order, "asc"); + assert.equal(criteria.pageNumber, 2); + assert.equal(criteria.pageSize, 10); + assert.deepEqual(criteria.filters, [ + { + field: "status", + operator: "EQUALS", + value: "approved", + }, + { + field: "proforma_date", + operator: "GREATER_THAN_OR_EQUAL", + value: "2026-08-01", + }, + { + field: "proforma_date", + operator: "LOWER_THAN_OR_EQUAL", + value: "2026-08-29", + }, + ]); +}); + +test("all elimina el filtro de fecha", () => { + const criteria = buildListProformasCriteria({ + datePreset: "all", + order: "desc", + orderBy: "proforma_date", + pageNumber: 0, + pageSize: 25, + q: "", + status: "all", + today: "2026-08-29", + }); + + assert.deepEqual(criteria.filters, []); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.ts b/modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.ts index 7d8aff28..407a577c 100644 --- a/modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.ts +++ b/modules/customer-invoices/src/web/proformas/list/utils/build-list-proformas-criteria.ts @@ -1,14 +1,18 @@ import type { CriteriaDTO } from "@erp/core"; -import type { ProformaListStatusFilter } from "../types/proforma-list-filters"; +import type { ProformaDatePreset, ProformaListStatusFilter } from "../types/proforma-list-filters"; + +import { buildProformaDateRange } from "./build-proforma-date-range"; type BuildListProformasCriteriaParams = { status: ProformaListStatusFilter; + datePreset: ProformaDatePreset; q: string; pageNumber: number; pageSize: number; orderBy: string; order: "asc" | "desc"; + today?: string; }; function buildStatusFilters(status: ProformaListStatusFilter): NonNullable { @@ -19,10 +23,33 @@ function buildStatusFilters(status: ProformaListStatusFilter): NonNullable { + const dateRange = buildProformaDateRange(datePreset, today); + + if (!dateRange) { + return []; + } + + return [ + { + field: "proforma_date", + operator: "GREATER_THAN_OR_EQUAL", + value: dateRange.from, + }, + { + field: "proforma_date", + operator: "LOWER_THAN_OR_EQUAL", + value: dateRange.to, + }, + ]; +} + +export function buildListProformasCriteria(params: BuildListProformasCriteriaParams): CriteriaDTO { const statusFilters = buildStatusFilters(params.status); + const dateFilters = buildDateFilters(params.datePreset, params.today); return { q: params.q, @@ -30,6 +57,6 @@ export function buildListProformasCriteria( pageSize: params.pageSize, orderBy: params.orderBy, order: params.order, - filters: statusFilters, + filters: [...statusFilters, ...dateFilters], }; } diff --git a/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.test.ts b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.test.ts new file mode 100644 index 00000000..991a67a4 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProformaDateRange } from "./build-proforma-date-range"; + +test("calcula last7Days desde una fecha fija", () => { + assert.deepEqual(buildProformaDateRange("last7Days", "2026-08-29"), { + from: "2026-08-22", + to: "2026-08-29", + }); +}); + +test("calcula currentMonth desde el primer día del mes actual hasta hoy", () => { + assert.deepEqual(buildProformaDateRange("currentMonth", "2026-08-29"), { + from: "2026-08-01", + to: "2026-08-29", + }); +}); + +test("calcula last3Months con semántica de calendario", () => { + assert.deepEqual(buildProformaDateRange("last3Months", "2026-08-29"), { + from: "2026-05-29", + to: "2026-08-29", + }); +}); + +test("calcula currentYear desde el 1 de enero hasta hoy", () => { + assert.deepEqual(buildProformaDateRange("currentYear", "2026-08-29"), { + from: "2026-01-01", + to: "2026-08-29", + }); +}); + +test("all no aplica límites", () => { + assert.equal(buildProformaDateRange("all", "2026-08-29"), null); +}); + +test("mantiene semántica correcta en enero", () => { + assert.deepEqual(buildProformaDateRange("currentYear", "2026-01-03"), { + from: "2026-01-01", + to: "2026-01-03", + }); + + assert.deepEqual(buildProformaDateRange("last3Months", "2026-01-03"), { + from: "2025-10-03", + to: "2026-01-03", + }); +}); + +test("ajusta el día al último día válido al restar meses", () => { + assert.deepEqual(buildProformaDateRange("last3Months", "2026-05-31"), { + from: "2026-02-28", + to: "2026-05-31", + }); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.ts b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.ts new file mode 100644 index 00000000..aa043453 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/build-proforma-date-range.ts @@ -0,0 +1,85 @@ +import { DateHelper } from "@repo/rdx-utils"; + +import type { ProformaDatePreset } from "../types/proforma-list-filters"; + +interface ProformaDateRange { + from: string; + to: string; +} + +const formatLocalDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +}; + +const toLocalDate = (date: string): Date => { + const normalized = DateHelper.normalizeToDate(date); + + if (!normalized) { + throw new Error(`Invalid local date "${date}"`); + } + + return normalized; +}; + +const startOfMonth = (date: Date): Date => { + return new Date(date.getFullYear(), date.getMonth(), 1); +}; + +const startOfYear = (date: Date): Date => { + return new Date(date.getFullYear(), 0, 1); +}; + +const subtractCalendarDays = (date: Date, days: number): Date => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - days); +}; + +const subtractCalendarMonths = (date: Date, months: number): Date => { + const targetMonthIndex = date.getMonth() - months; + const targetYear = date.getFullYear() + Math.floor(targetMonthIndex / 12); + const normalizedTargetMonth = ((targetMonthIndex % 12) + 12) % 12; + const lastDayOfTargetMonth = new Date(targetYear, normalizedTargetMonth + 1, 0).getDate(); + const targetDay = Math.min(date.getDate(), lastDayOfTargetMonth); + + return new Date(targetYear, normalizedTargetMonth, targetDay); +}; + +export const buildProformaDateRange = ( + preset: ProformaDatePreset, + today: string = DateHelper.buildTodayIsoDate() +): ProformaDateRange | null => { + if (preset === "all") { + return null; + } + + const currentDate = toLocalDate(today); + const to = formatLocalDate(currentDate); + + switch (preset) { + case "last7Days": + return { + from: formatLocalDate(subtractCalendarDays(currentDate, 7)), + to, + }; + case "currentMonth": + return { + from: formatLocalDate(startOfMonth(currentDate)), + to, + }; + case "last3Months": + return { + from: formatLocalDate(subtractCalendarMonths(currentDate, 3)), + to, + }; + case "currentYear": + return { + from: formatLocalDate(startOfYear(currentDate)), + to, + }; + default: + return null; + } +}; diff --git a/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.test.ts b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.test.ts new file mode 100644 index 00000000..5da3a6b9 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isProformaDatePreset, resolveProformaDatePreset } from "./resolve-proforma-date-preset"; + +test("acepta presets válidos desde URL", () => { + assert.equal(isProformaDatePreset("last7Days"), true); + assert.equal(resolveProformaDatePreset("currentMonth"), "currentMonth"); + assert.equal(resolveProformaDatePreset("last3Months"), "last3Months"); + assert.equal(resolveProformaDatePreset("currentYear"), "currentYear"); + assert.equal(resolveProformaDatePreset("all"), "all"); +}); + +test("usa currentYear cuando el parámetro no existe", () => { + assert.equal(resolveProformaDatePreset(null), "currentYear"); +}); + +test("usa currentYear cuando el parámetro es desconocido", () => { + assert.equal(isProformaDatePreset("unexpected"), false); + assert.equal(resolveProformaDatePreset("unexpected"), "currentYear"); +}); diff --git a/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.ts b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.ts new file mode 100644 index 00000000..2b519ed2 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/list/utils/resolve-proforma-date-preset.ts @@ -0,0 +1,24 @@ +import { + DEFAULT_PROFORMA_DATE_PRESET, + type ProformaDatePreset, +} from "../types/proforma-list-filters"; + +const PROFORMA_DATE_PRESETS = new Set([ + "last7Days", + "currentMonth", + "last3Months", + "currentYear", + "all", +]); + +export const isProformaDatePreset = (value: string | null): value is ProformaDatePreset => { + return value !== null && PROFORMA_DATE_PRESETS.has(value as ProformaDatePreset); +}; + +export const resolveProformaDatePreset = (value: string | null): ProformaDatePreset => { + if (!isProformaDatePreset(value)) { + return DEFAULT_PROFORMA_DATE_PRESET; + } + + return value; +}; diff --git a/modules/customer-invoices/src/web/recurring-proformas/create/index.ts b/modules/customer-invoices/src/web/recurring-proformas/create/index.ts new file mode 100644 index 00000000..35db5d01 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/create/index.ts @@ -0,0 +1 @@ +export * from "./recurring-proforma-create-page"; diff --git a/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx new file mode 100644 index 00000000..31ea4c16 --- /dev/null +++ b/modules/customer-invoices/src/web/recurring-proformas/create/recurring-proforma-create-page.tsx @@ -0,0 +1,70 @@ +import { useHookForm } from "@erp/core/hooks"; +import { RdxEditorPageLayout } from "@repo/rdx-ui/components"; +import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea } from "@repo/shadcn-ui/components"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useTranslation } from "../../i18n"; +import { useCreateRecurringProforma } from "../shared/hooks/use-create-recurring-proforma"; +import { + buildCreateRecurringProformaInput, + getCreatedRecurringProformaPath, + type RecurringProformaCreateForm, + RecurringProformaCreateFormSchema, +} from "./recurring-proforma-create-utils"; + +export const RecurringProformaCreatePage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const mutation = useCreateRecurringProforma(); + const [submitError, setSubmitError] = useState(null); + const form = useHookForm({ + resolverSchema: RecurringProformaCreateFormSchema, + initialValues: { name: "", description: "" }, + }); + + const onSubmit = form.handleSubmit(async (values) => { + if (mutation.isPending) return; + setSubmitError(null); + try { + const created = await mutation.mutateAsync(buildCreateRecurringProformaInput(values)); + navigate(getCreatedRecurringProformaPath(created.id)); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : t("recurring_proformas.create.error")); + } + }); + + return ( +
+
+ +
+
+

{t("recurring_proformas.create.title")}

+

{t("recurring_proformas.create.description")}

+
+ {submitError ?

{submitError}

: null} + + {t("recurring_proformas.create.form_title")} + +
+ + + {form.formState.errors.name ?

{t("recurring_proformas.create.name_required")}

: null} +
+
+ +