diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 32958d28..5341f32c 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -38,6 +38,7 @@ "title": "Settings", "description": "Configure the behavior of the active company.", "sections": { + "general": "General", "company": "Company", "billing": "Billing" }, diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index 69d4e101..9e5bb077 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -38,6 +38,7 @@ "title": "Configuración", "description": "Configura el comportamiento de la empresa activa.", "sections": { + "general": "General", "company": "Empresa", "billing": "Facturación" }, diff --git a/apps/web/src/settings/build-settings-navigation.test.ts b/apps/web/src/settings/build-settings-navigation.test.ts index 30a1dcce..673a0522 100644 --- a/apps/web/src/settings/build-settings-navigation.test.ts +++ b/apps/web/src/settings/build-settings-navigation.test.ts @@ -123,6 +123,43 @@ test("agrega entries de dos módulos en la misma sección", () => { ]); }); +test("incluye entries publicadas en la sección general", () => { + const result = buildSettingsNavigation({ + modules: [ + createModule({ + name: "Catalogs", + settings: { + entries: [ + { + id: "payment-methods", + sectionId: "general", + labelKey: "settings.paymentMethods.title", + path: "/settings/general/payment-methods", + }, + ], + }, + }), + ], + sections: settingsSectionCatalog, + }); + + assert.deepEqual(result, [ + { + id: "general", + labelKey: "web.settings.sections.general", + entries: [ + { + id: "payment-methods", + sectionId: "general", + labelKey: "settings.paymentMethods.title", + path: "/settings/general/payment-methods", + moduleName: "Catalogs", + }, + ], + }, + ]); +}); + test("falla si un módulo declara una sección inexistente", () => { assert.throws( () => diff --git a/apps/web/src/settings/build-settings-routes.test.tsx b/apps/web/src/settings/build-settings-routes.test.tsx index c8636844..1f8e63e7 100644 --- a/apps/web/src/settings/build-settings-routes.test.tsx +++ b/apps/web/src/settings/build-settings-routes.test.tsx @@ -47,6 +47,9 @@ const renderSettingsRouter = async ({ companyDetails: { title: "Datos de empresa", }, + paymentMethods: { + title: "Métodos de pago", + }, }, }); await i18n.changeLanguage("es"); @@ -117,6 +120,37 @@ test("resuelve un deep link modular bajo /settings", async () => { assert.match(html, /Contenido real de configuracion/); }); +test("resuelve un deep link modular bajo /settings/general/payment-methods", async () => { + const html = await renderSettingsRouter({ + initialEntry: "/settings/general/payment-methods", + modules: [ + createModule({ + name: SETTINGS_ROUTE_TEST_MODULE, + settings: { + entries: [ + { + id: "payment-methods", + sectionId: "general", + labelKey: "settings.paymentMethods.title", + path: "/settings/general/payment-methods", + }, + ], + }, + routes: [ + { + path: "settings/general/payment-methods", + element:
Contenido real de metodos de pago
, + }, + ], + }), + ], + }); + + assert.match(html, /Configuración/); + assert.match(html, /Métodos de pago/); + assert.match(html, /Contenido real de metodos de pago/); +}); + test("muestra 404 para una ruta inválida bajo /settings", async () => { const html = await renderSettingsRouter({ initialEntry: "/settings/unknown/path", diff --git a/apps/web/src/settings/settings-section-catalog.ts b/apps/web/src/settings/settings-section-catalog.ts index 5067efed..43773f5a 100644 --- a/apps/web/src/settings/settings-section-catalog.ts +++ b/apps/web/src/settings/settings-section-catalog.ts @@ -4,6 +4,10 @@ export interface SettingsSectionCatalogItem { } export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [ + { + id: "general", + labelKey: "web.settings.sections.general", + }, { id: "company", labelKey: "web.settings.sections.company", diff --git a/docs/catalogs/payment-methods-design.md b/docs/catalogs/payment-methods-design.md new file mode 100644 index 00000000..a5824d9e --- /dev/null +++ b/docs/catalogs/payment-methods-design.md @@ -0,0 +1,121 @@ +# Payment Methods Backend Design + +## Scope + +`PaymentMethod` es un catálogo `company-scoped` del módulo `catalogs`. + +## Shape + +Campos persistidos y expuestos para administración: + +- `id` +- `company_id` +- `name` +- `description` +- `is_active` +- `is_system` +- `is_default` +- `system_key` +- `created_at` +- `updated_at` +- `deleted_at` + +## System vs custom + +Reglas: + +- `is_system = true` implica `system_key != null` +- `is_system = false` implica `system_key = null` +- `is_system = true` no se puede editar, desactivar ni eliminar + +Identidad estable de métodos del sistema: + +- `cash` +- `bank_transfer` +- `card` +- `direct_debit` + +## Active and default + +Reglas: + +- `is_default = true` implica `is_active = true` +- un método `default` no se puede desactivar +- un método `default` no se puede eliminar +- el cambio de default solo se realiza mediante `PATCH /catalogs/payment-methods/:payment_method_id/default` + +La unicidad del default se garantiza mediante servicio transaccional y bloqueo por empresa. No se fuerza con constraint SQL adicional para evitar una solución opaca en MariaDB. + +## Bootstrap + +Endpoint explícito e idempotente: + +- `POST /catalogs/payment-methods/bootstrap-system` + +Comportamiento: + +- crea los cuatro métodos del sistema que falten +- restaura un system method soft-deleted si ya existía por `system_key` +- resincroniza `name`, `is_system`, `is_active`, `is_default = false` y `system_key` +- si la empresa no tiene default, establece `cash` como default inicial + +Métodos creados: + +- `Efectivo` +- `Transferencia bancaria` +- `Tarjeta` +- `Domiciliación bancaria` + +## Delete policy + +Un método solo puede eliminarse si: + +- `is_system === false` +- `is_default === false` +- no está referenciado por consumidores vivos + +Bloqueos actuales: + +- `customers.payment_method_id` +- `proformas.payment_method_id` +- `supplier_invoices.payment_method_id` + +No se bloquea por `issued_invoices`, porque ese módulo mantiene snapshot histórico suficiente del método de pago emitido. + +## Reference protection + +La arquitectura ideal sería mediante public services consumidores, pero `catalogs` ya es dependencia de `customers` y `customer-invoices`. Para evitar un ciclo de módulos, la protección referencial se implementa en `catalogs` con un checker de infraestructura específico contra los modelos registrados en Sequelize. + +## Consumers + +Consumidores actuales auditados: + +- `customers` +- `customer-invoices/proformas` +- `customer-invoices/issued-invoices` +- `supplier-invoices` +- `factuges` + +`factuges` no bloquea delete porque resuelve métodos de pago durante la importación y no mantiene una referencia persistente viva auditada en este flujo. + +## Frontend Settings + +La gestión administrativa frontend vive en: + +- `/settings/general/payment-methods` +- módulo técnico `catalogs` + +Características: + +- listado administrativo con activos e inactivos +- create/edit mediante dialogs +- cambio de default mediante radio por fila +- activación/desactivación con endpoints específicos +- protección visual para `system` y `default` +- confirmación de delete con manejo de conflicto `in use` + +Compatibilidad: + +- el listado administrativo reutiliza el catálogo compartido de `payment-methods` +- los selectores existentes siguen filtrando por `isActive=true` donde ya lo hacían +- esta fase no aplica el default automáticamente en Proformas ni Customers diff --git a/docs/catalogs/sql/README.md b/docs/catalogs/sql/README.md new file mode 100644 index 00000000..dabe950f --- /dev/null +++ b/docs/catalogs/sql/README.md @@ -0,0 +1,3 @@ +# Catalogs SQL + +Scripts manuales e idempotentes para evolución del módulo `catalogs`. diff --git a/docs/catalogs/sql/add-payment-method-default-and-system-key.sql b/docs/catalogs/sql/add-payment-method-default-and-system-key.sql new file mode 100644 index 00000000..488be01f --- /dev/null +++ b/docs/catalogs/sql/add-payment-method-default-and-system-key.sql @@ -0,0 +1,31 @@ +ALTER TABLE `payment_methods` + ADD COLUMN IF NOT EXISTS `is_default` TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_system`; + +ALTER TABLE `payment_methods` + ADD COLUMN IF NOT EXISTS `system_key` VARCHAR(64) NULL AFTER `is_default`; + +SET @index_exists := ( + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'payment_methods' + AND index_name = 'uq_payment_methods_company_system_key' +); + +SET @create_index_sql := IF( + @index_exists = 0, + 'CREATE UNIQUE INDEX `uq_payment_methods_company_system_key` ON `payment_methods` (`company_id`, `system_key`)', + 'SELECT 1' +); + +PREPARE stmt FROM @create_index_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `payment_methods` +SET `is_default` = 0 +WHERE `is_default` IS NULL; + +UPDATE `payment_methods` +SET `system_key` = NULL +WHERE `is_system` = 0; diff --git a/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts b/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts index 41cd4d68..5f5ecf6b 100644 --- a/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts +++ b/modules/catalogs/src/api/application/payment-methods/di/payment-method-deleter.di.ts @@ -1,10 +1,15 @@ import type { IPaymentMethodRepository } from "../repositories"; import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services"; +import type { IPaymentMethodUsageChecker } from "../services"; export const buildPaymentMethodDeleter = (params: { repository: IPaymentMethodRepository; + usageChecker: IPaymentMethodUsageChecker; }): IPaymentMethodDeleter => { - const { repository } = params; + const { repository, usageChecker } = params; - return new PaymentMethodDeleter(repository); + return new PaymentMethodDeleter({ + repository, + usageChecker, + }); }; diff --git a/modules/catalogs/src/api/application/payment-methods/mappers/create-payment-method-input.mapper.ts b/modules/catalogs/src/api/application/payment-methods/mappers/create-payment-method-input.mapper.ts index 43cb1299..314f532f 100644 --- a/modules/catalogs/src/api/application/payment-methods/mappers/create-payment-method-input.mapper.ts +++ b/modules/catalogs/src/api/application/payment-methods/mappers/create-payment-method-input.mapper.ts @@ -8,7 +8,7 @@ import { extractOrPushError, maybeFromNullableResult, } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; import type { CreatePaymentMethodRequestDTO } from "../../../../common"; import type { IPaymentMethodCreateProps } from "../../../domain"; @@ -48,6 +48,8 @@ export class CreatePaymentMethodInputMapper implements ICreatePaymentMethodInput description: description!, isActive: isActive!, isSystem: false, + isDefault: false, + systemKey: Maybe.none(), }; return Result.ok({ diff --git a/modules/catalogs/src/api/application/payment-methods/models/payment-method-detail.model.ts b/modules/catalogs/src/api/application/payment-methods/models/payment-method-detail.model.ts index 58df3fb5..663fb17a 100644 --- a/modules/catalogs/src/api/application/payment-methods/models/payment-method-detail.model.ts +++ b/modules/catalogs/src/api/application/payment-methods/models/payment-method-detail.model.ts @@ -8,4 +8,5 @@ export type PaymentMethodDetail = { description: Maybe; is_active: boolean; is_system: boolean; + is_default: boolean; }; diff --git a/modules/catalogs/src/api/application/payment-methods/models/payment-method-summary.model.ts b/modules/catalogs/src/api/application/payment-methods/models/payment-method-summary.model.ts index 1f8a3366..159a6dff 100644 --- a/modules/catalogs/src/api/application/payment-methods/models/payment-method-summary.model.ts +++ b/modules/catalogs/src/api/application/payment-methods/models/payment-method-summary.model.ts @@ -6,4 +6,5 @@ export type PaymentMethodSummary = { name: Name; isActive: boolean; isSystem: boolean; + isDefault: boolean; }; diff --git a/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts b/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts index e0f25e01..1942a5c3 100644 --- a/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts +++ b/modules/catalogs/src/api/application/payment-methods/public/mappers/payment-method-public-model.mapper.ts @@ -8,11 +8,11 @@ export class PaymentMethodPublicModelMapper { public toPublicModel(paymentMethod: PaymentMethod): PaymentMethodPublicModel { return { id: paymentMethod.id, - companyId: paymentMethod.companyId, + companyId: paymentMethod.companyId, name: paymentMethod.name.toPrimitive(), description: paymentMethod.description.map((value) => value.toPrimitive()), isActive: paymentMethod.isActive, + isDefault: paymentMethod.isDefault, }; } - } diff --git a/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts b/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts index 2fa25788..484c677a 100644 --- a/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts +++ b/modules/catalogs/src/api/application/payment-methods/public/models/payment-method-public.model.ts @@ -9,6 +9,5 @@ export interface PaymentMethodPublicModel { description: Maybe; isActive: boolean; - - + isDefault: boolean; } diff --git a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts index c2726f05..757e98a1 100644 --- a/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts +++ b/modules/catalogs/src/api/application/payment-methods/repositories/payment-method-repository.interface.ts @@ -2,7 +2,7 @@ import type { Criteria } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; import type { Collection, Maybe, Result } from "@repo/rdx-utils"; -import type { PaymentMethod } from "../../../domain"; +import type { PaymentMethod, PaymentMethodSystemKey } from "../../../domain"; import type { PaymentMethodSummary } from "../models"; export interface IPaymentMethodRepository { @@ -28,6 +28,33 @@ export interface IPaymentMethodRepository { criteria: Criteria, transaction?: unknown ): Promise, Error>>; + findDefaultInCompany( + companyId: UniqueID, + transaction?: unknown + ): Promise, Error>>; + findBySystemKeyInCompany( + companyId: UniqueID, + systemKey: PaymentMethodSystemKey, + transaction?: unknown, + options?: { includeDeleted?: boolean } + ): Promise, Error>>; + restoreByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: unknown + ): Promise>; + syncSystemPaymentMethodState(params: { + companyId: UniqueID; + id: UniqueID; + name: string; + isDefault: boolean; + systemKey: PaymentMethodSystemKey; + transaction?: unknown; + }): Promise>; + lockByCompanyId( + companyId: UniqueID, + transaction?: unknown + ): Promise>; deleteByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts new file mode 100644 index 00000000..55f030af --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/services/__tests__/payment-method-services.test.ts @@ -0,0 +1,362 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import type { Criteria } from "@repo/rdx-criteria/server"; +import { EntityNotFoundError } from "@erp/core/api"; +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Collection, Maybe, Result } from "@repo/rdx-utils"; + +import { PaymentMethod } from "../../../../domain/payment-methods/payment-method.aggregate"; +import { BootstrapSystemPaymentMethodsService } from "../bootstrap-system-payment-methods.service"; +import { PaymentMethodDeleter } from "../payment-method-deleter"; +import { SetDefaultPaymentMethodService } from "../set-default-payment-method.service"; +import type { IPaymentMethodRepository } from "../../repositories"; +import type { PaymentMethodSummary } from "../../models"; + +class InMemoryPaymentMethodRepository implements IPaymentMethodRepository { + private readonly items = new Map(); + private readonly deleted = new Set(); + + async create(paymentMethod: PaymentMethod): Promise> { + this.items.set(paymentMethod.id.toString(), paymentMethod); + return Result.ok(); + } + + async update(paymentMethod: PaymentMethod): Promise> { + this.items.set(paymentMethod.id.toString(), paymentMethod); + return Result.ok(); + } + + async existsByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const paymentMethod = this.items.get(id.toString()); + return Result.ok( + Boolean(paymentMethod && paymentMethod.companyId.toString() === companyId.toString()) + ); + } + + async getByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const paymentMethod = this.items.get(id.toString()); + + if (!paymentMethod || paymentMethod.companyId.toString() !== companyId.toString()) { + return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id.toString())); + } + + if (this.deleted.has(id.toString())) { + return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id.toString())); + } + + return Result.ok(paymentMethod); + } + + async findByIdInCompany( + companyId: UniqueID, + id: UniqueID + ): Promise, Error>> { + const result = await this.getByIdInCompany(companyId, id); + + if (result.isFailure) { + if (result.error instanceof EntityNotFoundError) { + return Result.ok(Maybe.none()); + } + + return Result.fail(result.error); + } + + return Result.ok(Maybe.some(result.data)); + } + + async findByCriteriaInCompany(): Promise, Error>> { + return Result.ok(Collection.create([])); + } + + async findDefaultInCompany(companyId: UniqueID): Promise, Error>> { + const paymentMethod = + [...this.items.values()].find( + (item) => + item.companyId.toString() === companyId.toString() && + item.isDefault && + !this.deleted.has(item.id.toString()) + ) ?? null; + + return paymentMethod ? Result.ok(Maybe.some(paymentMethod)) : Result.ok(Maybe.none()); + } + + async findBySystemKeyInCompany( + companyId: UniqueID, + systemKey: "cash" | "bank_transfer" | "card" | "direct_debit", + _transaction?: unknown, + options?: { includeDeleted?: boolean } + ): Promise, Error>> { + const paymentMethod = + [...this.items.values()].find((item) => { + const matchesCompany = item.companyId.toString() === companyId.toString(); + const matchesKey = item.systemKey.match( + (value) => value === systemKey, + () => false + ); + const includeDeleted = options?.includeDeleted === true; + + return ( + matchesCompany && + matchesKey && + (includeDeleted || !this.deleted.has(item.id.toString())) + ); + }) ?? null; + + return paymentMethod ? Result.ok(Maybe.some(paymentMethod)) : Result.ok(Maybe.none()); + } + + async restoreByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const result = await this.existsByIdInCompany(companyId, id); + if (result.isFailure) { + return Result.fail(result.error); + } + + if (!result.data) { + return Result.ok(false); + } + + this.deleted.delete(id.toString()); + return Result.ok(true); + } + + async syncSystemPaymentMethodState(params: { + companyId: UniqueID; + id: UniqueID; + name: string; + isDefault: boolean; + systemKey: "cash" | "bank_transfer" | "card" | "direct_debit"; + }): Promise> { + const paymentMethodResult = await this.getByIdInCompany(params.companyId, params.id); + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + const current = paymentMethodResult.data; + const nameResult = Name.create(params.name); + assert.equal(nameResult.isSuccess, true); + + const updatedResult = PaymentMethod.create( + { + companyId: current.companyId, + name: nameResult.data, + description: current.description, + isActive: true, + isSystem: true, + isDefault: params.isDefault, + systemKey: Maybe.some(params.systemKey), + }, + current.id + ); + + assert.equal(updatedResult.isSuccess, true); + this.items.set(current.id.toString(), updatedResult.data); + + return Result.ok(); + } + + async lockByCompanyId(): Promise> { + return Result.ok(); + } + + async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise> { + const result = await this.existsByIdInCompany(companyId, id); + if (result.isFailure) { + return Result.fail(result.error); + } + + if (!result.data) { + return Result.fail(new EntityNotFoundError("PaymentMethod", "id", id.toString())); + } + + this.deleted.add(id.toString()); + return Result.ok(true); + } +} + +const buildPaymentMethod = (params: { + companyId: UniqueID; + name: string; + isActive?: boolean; + isSystem?: boolean; + isDefault?: boolean; + systemKey?: "cash" | "bank_transfer" | "card" | "direct_debit"; +}) => { + const nameResult = Name.create(params.name); + assert.equal(nameResult.isSuccess, true); + + const paymentMethodResult = PaymentMethod.create( + { + companyId: params.companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: params.isActive ?? true, + isSystem: params.isSystem ?? false, + isDefault: params.isDefault ?? false, + systemKey: params.systemKey ? Maybe.some(params.systemKey) : Maybe.none(), + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentMethodResult.isSuccess, true); + + return paymentMethodResult.data; +}; + +describe("Payment method services", () => { + test("set default swaps previous default atomically in repository state", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const service = new SetDefaultPaymentMethodService(repository); + const companyId = UniqueID.generateNewID(); + + const cash = buildPaymentMethod({ + companyId, + name: "Efectivo", + isDefault: true, + }); + const card = buildPaymentMethod({ + companyId, + name: "Tarjeta", + }); + + await repository.create(cash); + await repository.create(card); + + const result = await service.setDefault({ + companyId, + paymentMethodId: card.id, + }); + + assert.equal(result.isSuccess, true); + + const updatedCash = await repository.getByIdInCompany(companyId, cash.id); + const updatedCard = await repository.getByIdInCompany(companyId, card.id); + + assert.equal(updatedCash.isSuccess, true); + assert.equal(updatedCard.isSuccess, true); + assert.equal(updatedCash.data.isDefault, false); + assert.equal(updatedCard.data.isDefault, true); + }); + + test("set default is idempotent when method is already default", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const service = new SetDefaultPaymentMethodService(repository); + const companyId = UniqueID.generateNewID(); + + const cash = buildPaymentMethod({ + companyId, + name: "Efectivo", + isDefault: true, + }); + + await repository.create(cash); + + const result = await service.setDefault({ + companyId, + paymentMethodId: cash.id, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.isDefault, true); + }); + + test("set default rejects inactive methods", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const service = new SetDefaultPaymentMethodService(repository); + const companyId = UniqueID.generateNewID(); + + const inactive = buildPaymentMethod({ + companyId, + name: "Tarjeta", + isActive: false, + }); + + await repository.create(inactive); + + const result = await service.setDefault({ + companyId, + paymentMethodId: inactive.id, + }); + + assert.equal(result.isFailure, true); + }); + + test("set default preserves company isolation", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const service = new SetDefaultPaymentMethodService(repository); + const companyA = UniqueID.generateNewID(); + const companyB = UniqueID.generateNewID(); + + const methodFromCompanyB = buildPaymentMethod({ + companyId: companyB, + name: "Tarjeta", + }); + + await repository.create(methodFromCompanyB); + + const result = await service.setDefault({ + companyId: companyA, + paymentMethodId: methodFromCompanyB.id, + }); + + assert.equal(result.isFailure, true); + }); + + test("bootstrap creates four system methods and exactly one default", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const setDefaultService = new SetDefaultPaymentMethodService(repository); + const service = new BootstrapSystemPaymentMethodsService({ + repository, + setDefaultService, + }); + const companyId = UniqueID.generateNewID(); + + const result = await service.bootstrap({ companyId }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.length, 4); + assert.equal(result.data.filter((item) => item.isDefault).length, 1); + assert.equal(result.data.some((item) => item.systemKey.match((key) => key === "cash", () => false) && item.isDefault), true); + }); + + test("bootstrap is idempotent", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const setDefaultService = new SetDefaultPaymentMethodService(repository); + const service = new BootstrapSystemPaymentMethodsService({ + repository, + setDefaultService, + }); + const companyId = UniqueID.generateNewID(); + + const first = await service.bootstrap({ companyId }); + const second = await service.bootstrap({ companyId }); + + assert.equal(first.isSuccess, true); + assert.equal(second.isSuccess, true); + assert.equal(second.data.length, 4); + }); + + test("delete blocks referenced payment methods", async () => { + const repository = new InMemoryPaymentMethodRepository(); + const deleter = new PaymentMethodDeleter({ + repository, + usageChecker: { + findUsage: async () => Result.ok(["customers"]), + }, + }); + const companyId = UniqueID.generateNewID(); + const paymentMethod = buildPaymentMethod({ + companyId, + name: "Bizum", + }); + + await repository.create(paymentMethod); + + const result = await deleter.deleteById({ + companyId, + id: paymentMethod.id, + }); + + assert.equal(result.isFailure, true); + }); +}); diff --git a/modules/catalogs/src/api/application/payment-methods/services/bootstrap-system-payment-methods.service.ts b/modules/catalogs/src/api/application/payment-methods/services/bootstrap-system-payment-methods.service.ts new file mode 100644 index 00000000..b660b8be --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/services/bootstrap-system-payment-methods.service.ts @@ -0,0 +1,208 @@ +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { PaymentMethod, type PaymentMethodSystemKey } from "../../../domain"; +import type { IPaymentMethodRepository } from "../repositories"; +import type { ISetDefaultPaymentMethodService } from "./set-default-payment-method.service"; + +type SystemPaymentMethodDefinition = { + systemKey: PaymentMethodSystemKey; + name: string; + isDefault: boolean; +}; + +const SYSTEM_PAYMENT_METHOD_DEFINITIONS: SystemPaymentMethodDefinition[] = [ + { + systemKey: "cash", + name: "Efectivo", + isDefault: true, + }, + { + systemKey: "bank_transfer", + name: "Transferencia bancaria", + isDefault: false, + }, + { + systemKey: "card", + name: "Tarjeta", + isDefault: false, + }, + { + systemKey: "direct_debit", + name: "Domiciliación bancaria", + isDefault: false, + }, +]; + +export interface IBootstrapSystemPaymentMethodsService { + bootstrap(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise>; +} + +export class BootstrapSystemPaymentMethodsService implements IBootstrapSystemPaymentMethodsService { + public constructor( + private readonly deps: { + repository: IPaymentMethodRepository; + setDefaultService: ISetDefaultPaymentMethodService; + } + ) {} + + public async bootstrap(params: { + companyId: UniqueID; + transaction?: unknown; + }): Promise> { + const { companyId, transaction } = params; + + const lockResult = await this.deps.repository.lockByCompanyId(companyId, transaction); + if (lockResult.isFailure) { + return Result.fail(lockResult.error); + } + + const bootstrappedPaymentMethods: PaymentMethod[] = []; + + for (const definition of SYSTEM_PAYMENT_METHOD_DEFINITIONS) { + const paymentMethodResult = await this.ensureSystemPaymentMethod({ + companyId, + definition, + transaction, + }); + + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + bootstrappedPaymentMethods.push(paymentMethodResult.data); + } + + const currentDefaultResult = await this.deps.repository.findDefaultInCompany( + companyId, + transaction + ); + if (currentDefaultResult.isFailure) { + return Result.fail(currentDefaultResult.error); + } + + if (currentDefaultResult.data.isNone()) { + const cashPaymentMethod = bootstrappedPaymentMethods.find( + (paymentMethod) => + paymentMethod.systemKey.match( + (systemKey) => systemKey === "cash", + () => false + ) + ); + + if (!cashPaymentMethod) { + return Result.fail(new Error("Cash system payment method bootstrap failed.")); + } + + const setDefaultResult = await this.deps.setDefaultService.setDefault({ + companyId, + paymentMethodId: cashPaymentMethod.id, + transaction, + }); + + if (setDefaultResult.isFailure) { + return Result.fail(setDefaultResult.error); + } + + const updatedItems = bootstrappedPaymentMethods.map((paymentMethod) => + paymentMethod.id.toString() === setDefaultResult.data.id.toString() + ? setDefaultResult.data + : paymentMethod + ); + + return Result.ok(updatedItems); + } + + return Result.ok(bootstrappedPaymentMethods); + } + + private async ensureSystemPaymentMethod(params: { + companyId: UniqueID; + definition: SystemPaymentMethodDefinition; + transaction?: unknown; + }): Promise> { + const { companyId, definition, transaction } = params; + + const existingResult = await this.deps.repository.findBySystemKeyInCompany( + companyId, + definition.systemKey, + transaction, + { includeDeleted: true } + ); + + if (existingResult.isFailure) { + return Result.fail(existingResult.error); + } + + if (existingResult.data.isSome()) { + const existingPaymentMethod = existingResult.data.unwrap(); + + const restoreResult = await this.deps.repository.restoreByIdInCompany( + companyId, + existingPaymentMethod.id, + transaction + ); + + if (restoreResult.isFailure) { + return Result.fail(restoreResult.error); + } + + const syncResult = await this.deps.repository.syncSystemPaymentMethodState({ + companyId, + id: existingPaymentMethod.id, + name: definition.name, + isDefault: false, + systemKey: definition.systemKey, + transaction, + }); + + if (syncResult.isFailure) { + return Result.fail(syncResult.error); + } + + const refreshedResult = await this.deps.repository.getByIdInCompany( + companyId, + existingPaymentMethod.id, + transaction + ); + + if (refreshedResult.isFailure) { + return Result.fail(refreshedResult.error); + } + + return Result.ok(refreshedResult.data); + } + + const nameResult = Name.create(definition.name); + if (nameResult.isFailure) { + return Result.fail(nameResult.error); + } + + const paymentMethodResult = PaymentMethod.create( + { + companyId, + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: true, + isDefault: false, + systemKey: Maybe.some(definition.systemKey), + }, + UniqueID.generateNewID() + ); + + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + const createResult = await this.deps.repository.create(paymentMethodResult.data, transaction); + if (createResult.isFailure) { + return Result.fail(createResult.error); + } + + return Result.ok(paymentMethodResult.data); + } +} diff --git a/modules/catalogs/src/api/application/payment-methods/services/index.ts b/modules/catalogs/src/api/application/payment-methods/services/index.ts index 3bb22624..c274fec1 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/index.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/index.ts @@ -1,8 +1,11 @@ export * from "./payment-method-creator"; +export * from "./bootstrap-system-payment-methods.service"; export * from "./payment-method-deleter"; export * from "./payment-method-disabler"; export * from "./payment-method-enabler"; export * from "./payment-method-finder"; export * from "./payment-method-public-services"; +export * from "./payment-method-usage-checker"; export * from "./payment-method-status-changer"; export * from "./payment-method-updater"; +export * from "./set-default-payment-method.service"; diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts index a916946a..1c177fae 100644 --- a/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-deleter.ts @@ -1,8 +1,9 @@ import { Result } from "@repo/rdx-utils"; import type { PaymentMethod } from "../../../domain"; -import { PaymentMethodCannotBeDeletedError } from "../../../domain"; +import { PaymentMethodInUseError } from "../../../domain"; import type { IPaymentMethodRepository } from "../repositories"; +import type { IPaymentMethodUsageChecker } from "./payment-method-usage-checker"; export interface IPaymentMethodDeleter { delete(params: { @@ -12,7 +13,12 @@ export interface IPaymentMethodDeleter { } export class PaymentMethodDeleter implements IPaymentMethodDeleter { - public constructor(private readonly repository: IPaymentMethodRepository) {} + public constructor( + private readonly deps: { + repository: IPaymentMethodRepository; + usageChecker: IPaymentMethodUsageChecker; + } + ) {} public async delete(params: { paymentMethod: PaymentMethod; @@ -20,13 +26,31 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter { }): Promise> { const { paymentMethod, transaction } = params; - if (paymentMethod.isSystem) { + const invariantResult = paymentMethod.ensureCanBeDeleted(); + if (invariantResult.isFailure) { + return Result.fail(invariantResult.error); + } + + const usageResult = await this.deps.usageChecker.findUsage({ + companyId: paymentMethod.companyId, + paymentMethodId: paymentMethod.id, + transaction, + }); + + if (usageResult.isFailure) { + return Result.fail(usageResult.error); + } + + if (usageResult.data.length > 0) { + const sources = usageResult.data.map((detail) => detail.source).join(", "); return Result.fail( - new PaymentMethodCannotBeDeletedError("System payment methods cannot be deleted.") + new PaymentMethodInUseError( + `Payment method cannot be deleted because it is still referenced by: ${sources}.` + ) ); } - const deleteResult = await this.repository.deleteByIdInCompany( + const deleteResult = await this.deps.repository.deleteByIdInCompany( paymentMethod.companyId, paymentMethod.id, transaction diff --git a/modules/catalogs/src/api/application/payment-methods/services/payment-method-usage-checker.ts b/modules/catalogs/src/api/application/payment-methods/services/payment-method-usage-checker.ts new file mode 100644 index 00000000..216ca827 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/services/payment-method-usage-checker.ts @@ -0,0 +1,84 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +export type PaymentMethodUsageSource = "customers" | "proformas" | "supplier_invoices"; + +export interface PaymentMethodUsageDetail { + source: PaymentMethodUsageSource; +} + +export interface IPaymentMethodUsageChecker { + findUsage(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise>; +} + +export class PaymentMethodUsageChecker implements IPaymentMethodUsageChecker { + public constructor( + private readonly deps: { + customersUsageFinder: { + existsByPaymentMethodId(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + proformasUsageFinder: { + existsByPaymentMethodId(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + supplierInvoicesUsageFinder: { + existsByPaymentMethodId(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise>; + }; + } + ) {} + + public async findUsage(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise> { + const [customersUsage, proformasUsage, supplierInvoicesUsage] = await Promise.all([ + this.deps.customersUsageFinder.existsByPaymentMethodId(params), + this.deps.proformasUsageFinder.existsByPaymentMethodId(params), + this.deps.supplierInvoicesUsageFinder.existsByPaymentMethodId(params), + ]); + + if (customersUsage.isFailure) { + return Result.fail(customersUsage.error); + } + + if (proformasUsage.isFailure) { + return Result.fail(proformasUsage.error); + } + + if (supplierInvoicesUsage.isFailure) { + return Result.fail(supplierInvoicesUsage.error); + } + + const usage: PaymentMethodUsageDetail[] = []; + + if (customersUsage.data) { + usage.push({ source: "customers" }); + } + + if (proformasUsage.data) { + usage.push({ source: "proformas" }); + } + + if (supplierInvoicesUsage.data) { + usage.push({ source: "supplier_invoices" }); + } + + return Result.ok(usage); + } +} diff --git a/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts new file mode 100644 index 00000000..3d8d1bf6 --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/services/set-default-payment-method.service.ts @@ -0,0 +1,86 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { PaymentMethodCannotBeDefaultError, type PaymentMethod } from "../../../domain"; +import type { IPaymentMethodRepository } from "../repositories"; + +export interface ISetDefaultPaymentMethodService { + setDefault(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise>; +} + +export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodService { + public constructor(private readonly repository: IPaymentMethodRepository) {} + + public async setDefault(params: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }): Promise> { + const { companyId, paymentMethodId, transaction } = params; + + const lockResult = await this.repository.lockByCompanyId(companyId, transaction); + if (lockResult.isFailure) { + return Result.fail(lockResult.error); + } + + const paymentMethodResult = await this.repository.getByIdInCompany( + companyId, + paymentMethodId, + transaction + ); + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + const paymentMethod = paymentMethodResult.data; + + if (!paymentMethod.isActive) { + return Result.fail( + new PaymentMethodCannotBeDefaultError("Inactive payment methods cannot be marked default.") + ); + } + + if (paymentMethod.isDefault) { + return Result.ok(paymentMethod); + } + + const currentDefaultResult = await this.repository.findDefaultInCompany(companyId, transaction); + if (currentDefaultResult.isFailure) { + return Result.fail(currentDefaultResult.error); + } + + const currentDefault = currentDefaultResult.data.getOrUndefined() ?? null; + + if (currentDefault && currentDefault.id.toString() !== paymentMethod.id.toString()) { + const unmarkResult = currentDefault.unmarkAsDefault(); + if (unmarkResult.isFailure) { + return Result.fail(unmarkResult.error); + } + + if (unmarkResult.data) { + const saveCurrentResult = await this.repository.update(currentDefault, transaction); + if (saveCurrentResult.isFailure) { + return Result.fail(saveCurrentResult.error); + } + } + } + + const markResult = paymentMethod.markAsDefault(); + if (markResult.isFailure) { + return Result.fail(markResult.error); + } + + if (markResult.data) { + const savePaymentMethodResult = await this.repository.update(paymentMethod, transaction); + if (savePaymentMethodResult.isFailure) { + return Result.fail(savePaymentMethodResult.error); + } + } + + return Result.ok(paymentMethod); + } +} diff --git a/modules/catalogs/src/api/application/payment-methods/snapshot-builders/full/payment-method-full-snapshot-builder.ts b/modules/catalogs/src/api/application/payment-methods/snapshot-builders/full/payment-method-full-snapshot-builder.ts index 3933e3dc..0dfcbe5d 100644 --- a/modules/catalogs/src/api/application/payment-methods/snapshot-builders/full/payment-method-full-snapshot-builder.ts +++ b/modules/catalogs/src/api/application/payment-methods/snapshot-builders/full/payment-method-full-snapshot-builder.ts @@ -20,6 +20,7 @@ export class PaymentMethodFullSnapshotBuilder implements IPaymentMethodFullSnaps is_active: paymentMethod.isActive, is_system: paymentMethod.isSystem, + is_default: paymentMethod.isDefault, }; } } diff --git a/modules/catalogs/src/api/application/payment-methods/snapshot-builders/summary/payment-method-summary-snapshot-builder.ts b/modules/catalogs/src/api/application/payment-methods/snapshot-builders/summary/payment-method-summary-snapshot-builder.ts index ffd2b517..75af6e49 100644 --- a/modules/catalogs/src/api/application/payment-methods/snapshot-builders/summary/payment-method-summary-snapshot-builder.ts +++ b/modules/catalogs/src/api/application/payment-methods/snapshot-builders/summary/payment-method-summary-snapshot-builder.ts @@ -16,6 +16,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar is_system: paymentMethod.isSystem, is_active: paymentMethod.isActive, + is_default: paymentMethod.isDefault, }; } } diff --git a/modules/catalogs/src/api/application/payment-methods/use-cases/bootstrap-system-payment-methods.use-case.ts b/modules/catalogs/src/api/application/payment-methods/use-cases/bootstrap-system-payment-methods.use-case.ts new file mode 100644 index 00000000..e295f09b --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/use-cases/bootstrap-system-payment-methods.use-case.ts @@ -0,0 +1,46 @@ +import type { BootstrapSystemPaymentMethodsResponseDTO } from "@erp/catalogs/common"; +import type { ITransactionManager } from "@erp/core/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IBootstrapSystemPaymentMethodsService } from "../services"; +import type { IPaymentMethodFullSnapshotBuilder } from "../snapshot-builders"; + +export type BootstrapSystemPaymentMethodsUseCaseInput = { + companyId: UniqueID; +}; + +export class BootstrapSystemPaymentMethodsUseCase { + public constructor( + private readonly deps: { + bootstrapService: IBootstrapSystemPaymentMethodsService; + fullSnapshotBuilder: IPaymentMethodFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute( + params: BootstrapSystemPaymentMethodsUseCaseInput + ): Promise> { + const { companyId } = params; + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + try { + const result = await this.deps.bootstrapService.bootstrap({ + companyId, + transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok({ + items: result.data.map((paymentMethod) => this.deps.fullSnapshotBuilder.toOutput(paymentMethod)), + }); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/catalogs/src/api/application/payment-methods/use-cases/index.ts b/modules/catalogs/src/api/application/payment-methods/use-cases/index.ts index 9c5c2ff7..4d3891b1 100644 --- a/modules/catalogs/src/api/application/payment-methods/use-cases/index.ts +++ b/modules/catalogs/src/api/application/payment-methods/use-cases/index.ts @@ -1,7 +1,9 @@ +export * from "./bootstrap-system-payment-methods.use-case"; export * from "./create-payment-method.use-case"; export * from "./delete-payment-method-by-id.use-case"; export * from "./disable-payment-method-by-id.use-case"; export * from "./enable-payment-method-by-id.use-case"; export * from "./get-payment-method-by-id.use-case"; export * from "./list-payment-methods.use-case"; +export * from "./set-default-payment-method-by-id.use-case"; export * from "./update-payment-method-by-id.use-case"; diff --git a/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts b/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts new file mode 100644 index 00000000..85e7ff3e --- /dev/null +++ b/modules/catalogs/src/api/application/payment-methods/use-cases/set-default-payment-method-by-id.use-case.ts @@ -0,0 +1,48 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IPaymentMethodFullSnapshotBuilder } from "../snapshot-builders"; +import type { ISetDefaultPaymentMethodService } from "../services"; + +export type SetDefaultPaymentMethodByIdUseCaseInput = { + companyId: UniqueID; + payment_method_id: string; +}; + +export class SetDefaultPaymentMethodByIdUseCase { + public constructor( + private readonly deps: { + setDefaultService: ISetDefaultPaymentMethodService; + fullSnapshotBuilder: IPaymentMethodFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: SetDefaultPaymentMethodByIdUseCaseInput) { + const { companyId, payment_method_id } = params; + + const idOrError = UniqueID.create(payment_method_id); + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + return this.deps.transactionManager.complete(async (transaction: unknown) => { + try { + const result = await this.deps.setDefaultService.setDefault({ + companyId, + paymentMethodId: idOrError.data, + transaction, + }); + + if (result.isFailure) { + return Result.fail(result.error); + } + + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(result.data)); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/catalogs/src/api/domain/payment-methods/__tests__/payment-method.aggregate.test.ts b/modules/catalogs/src/api/domain/payment-methods/__tests__/payment-method.aggregate.test.ts new file mode 100644 index 00000000..7107e21a --- /dev/null +++ b/modules/catalogs/src/api/domain/payment-methods/__tests__/payment-method.aggregate.test.ts @@ -0,0 +1,108 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { PaymentMethod } from "../payment-method.aggregate"; + +const createPaymentMethod = (params?: { + isActive?: boolean; + isSystem?: boolean; + isDefault?: boolean; +}) => { + const nameResult = Name.create("Transferencia"); + assert.equal(nameResult.isSuccess, true); + + const paymentMethodResult = PaymentMethod.create( + { + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: params?.isActive ?? true, + isSystem: params?.isSystem ?? false, + isDefault: params?.isDefault ?? false, + systemKey: Maybe.none(), + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentMethodResult.isSuccess, true); + + return paymentMethodResult.data; +}; + +describe("PaymentMethod aggregate", () => { + test("rejects default inactive payment methods", () => { + const nameResult = Name.create("Tarjeta"); + assert.equal(nameResult.isSuccess, true); + + const result = PaymentMethod.create( + { + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: false, + isSystem: false, + isDefault: true, + systemKey: Maybe.none(), + }, + UniqueID.generateNewID() + ); + + assert.equal(result.isFailure, true); + }); + + test("blocks disabling default payment methods", () => { + const paymentMethod = createPaymentMethod({ isDefault: true }); + + const result = paymentMethod.disable(); + + assert.equal(result.isFailure, true); + }); + + test("blocks deleting default payment methods", () => { + const paymentMethod = createPaymentMethod({ isDefault: true }); + + const result = paymentMethod.ensureCanBeDeleted(); + + assert.equal(result.isFailure, true); + }); + + test("blocks updating system payment methods", () => { + const nameResult = Name.create("Efectivo"); + assert.equal(nameResult.isSuccess, true); + + const paymentMethodResult = PaymentMethod.create( + { + companyId: UniqueID.generateNewID(), + name: nameResult.data, + description: Maybe.none(), + isActive: true, + isSystem: true, + isDefault: false, + systemKey: Maybe.some("cash"), + }, + UniqueID.generateNewID() + ); + + assert.equal(paymentMethodResult.isSuccess, true); + + const newNameResult = Name.create("Caja"); + assert.equal(newNameResult.isSuccess, true); + + const result = paymentMethodResult.data.update({ + name: newNameResult.data, + }); + + assert.equal(result.isFailure, true); + }); + + test("allows deleting custom non-default payment methods", () => { + const paymentMethod = createPaymentMethod(); + + const result = paymentMethod.ensureCanBeDeleted(); + + assert.equal(result.isSuccess, true); + }); +}); diff --git a/modules/catalogs/src/api/domain/payment-methods/errors.ts b/modules/catalogs/src/api/domain/payment-methods/errors.ts index 4a8ff677..4a7f954b 100644 --- a/modules/catalogs/src/api/domain/payment-methods/errors.ts +++ b/modules/catalogs/src/api/domain/payment-methods/errors.ts @@ -37,6 +37,15 @@ export const isPaymentMethodCannotBeDeletedError = ( // +export class PaymentMethodInUseError extends DomainError { + public readonly code = "PAYMENT_METHOD_IN_USE" as const; +} + +export const isPaymentMethodInUseError = (e: unknown): e is PaymentMethodInUseError => + e instanceof PaymentMethodInUseError; + +// + export class PaymentMethodCannotBeDisabledError extends DomainError { public readonly code = "PAYMENT_METHOD_CANNOT_BE_DISABLED" as const; } @@ -47,6 +56,16 @@ export const isPaymentMethodCannotBeDisabledError = ( // +export class PaymentMethodCannotBeDefaultError extends DomainError { + public readonly code = "PAYMENT_METHOD_CANNOT_BE_DEFAULT" as const; +} + +export const isPaymentMethodCannotBeDefaultError = ( + e: unknown +): e is PaymentMethodCannotBeDefaultError => e instanceof PaymentMethodCannotBeDefaultError; + +// + export class PaymentMethodCannotBeEnabledError extends DomainError { public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const; } diff --git a/modules/catalogs/src/api/domain/payment-methods/index.ts b/modules/catalogs/src/api/domain/payment-methods/index.ts index 16cffcb8..f1911097 100644 --- a/modules/catalogs/src/api/domain/payment-methods/index.ts +++ b/modules/catalogs/src/api/domain/payment-methods/index.ts @@ -1,3 +1,4 @@ export * from "./errors"; export * from "./payment-method.aggregate"; export * from "./payment-method-name"; +export * from "./payment-method-system-key"; diff --git a/modules/catalogs/src/api/domain/payment-methods/payment-method-system-key.ts b/modules/catalogs/src/api/domain/payment-methods/payment-method-system-key.ts new file mode 100644 index 00000000..565c2089 --- /dev/null +++ b/modules/catalogs/src/api/domain/payment-methods/payment-method-system-key.ts @@ -0,0 +1,12 @@ +export const PAYMENT_METHOD_SYSTEM_KEYS = [ + "cash", + "bank_transfer", + "card", + "direct_debit", +] as const; + +export type PaymentMethodSystemKey = (typeof PAYMENT_METHOD_SYSTEM_KEYS)[number]; + +export const isPaymentMethodSystemKey = (value: string): value is PaymentMethodSystemKey => { + return PAYMENT_METHOD_SYSTEM_KEYS.includes(value as PaymentMethodSystemKey); +}; diff --git a/modules/catalogs/src/api/domain/payment-methods/payment-method.aggregate.ts b/modules/catalogs/src/api/domain/payment-methods/payment-method.aggregate.ts index 4f663698..7c057f89 100644 --- a/modules/catalogs/src/api/domain/payment-methods/payment-method.aggregate.ts +++ b/modules/catalogs/src/api/domain/payment-methods/payment-method.aggregate.ts @@ -1,7 +1,13 @@ import { AggregateRoot, type Name, type TextValue, type UniqueID } from "@repo/rdx-ddd"; import { type Maybe, Result } from "@repo/rdx-utils"; -import { PaymentMethodCannotBeDisabledError, PaymentMethodCannotBeUpdatedError } from "./errors"; +import { + PaymentMethodCannotBeDefaultError, + PaymentMethodCannotBeDisabledError, + PaymentMethodCannotBeDeletedError, + PaymentMethodCannotBeUpdatedError, +} from "./errors"; +import type { PaymentMethodSystemKey } from "./payment-method-system-key"; export interface IPaymentMethodCreateProps { companyId: UniqueID; @@ -9,6 +15,8 @@ export interface IPaymentMethodCreateProps { description: Maybe; isActive: boolean; isSystem: boolean; + isDefault: boolean; + systemKey: Maybe; } export type PaymentMethodPatchProps = Partial<{ @@ -57,6 +65,20 @@ export class PaymentMethod extends AggregateRoot { return Result.fail(new Error("Payment method name is required")); } + if (props.isDefault && !props.isActive) { + return Result.fail( + new PaymentMethodCannotBeDefaultError("Default payment methods must remain active.") + ); + } + + if (props.isSystem && props.systemKey.isNone()) { + return Result.fail(new Error("System payment methods require a system key.")); + } + + if (!props.isSystem && props.systemKey.isSome()) { + return Result.fail(new Error("Custom payment methods cannot define a system key.")); + } + return Result.ok(); } @@ -88,6 +110,14 @@ export class PaymentMethod extends AggregateRoot { return this.props.isSystem; } + public get isDefault(): boolean { + return this.props.isDefault; + } + + public get systemKey(): Maybe { + return this.props.systemKey; + } + public update(patchProps: PaymentMethodPatchProps): Result { if (this.isSystem) { return Result.fail( @@ -133,6 +163,14 @@ export class PaymentMethod extends AggregateRoot { ); } + if (this.isDefault) { + return Result.fail( + new PaymentMethodCannotBeDisabledError( + "Default payment methods cannot be disabled until another default is selected." + ) + ); + } + if (!this.isActive) { return Result.ok(false); } @@ -156,6 +194,50 @@ export class PaymentMethod extends AggregateRoot { return Result.ok(true); } + public markAsDefault(): Result { + if (!this.isActive) { + return Result.fail( + new PaymentMethodCannotBeDefaultError("Inactive payment methods cannot be marked default.") + ); + } + + if (this.isDefault) { + return Result.ok(false); + } + + this.props.isDefault = true; + + return Result.ok(true); + } + + public unmarkAsDefault(): Result { + if (!this.isDefault) { + return Result.ok(false); + } + + this.props.isDefault = false; + + return Result.ok(true); + } + + public ensureCanBeDeleted(): Result { + if (this.isSystem) { + return Result.fail( + new PaymentMethodCannotBeDeletedError("System payment methods cannot be deleted.") + ); + } + + if (this.isDefault) { + return Result.fail( + new PaymentMethodCannotBeDeletedError( + "Default payment methods cannot be deleted until another default is selected." + ) + ); + } + + return Result.ok(); + } + public toJSON() { return { id: this.id.toPrimitive(), @@ -167,6 +249,7 @@ export class PaymentMethod extends AggregateRoot { ), is_active: this.isActive, is_system: this.isSystem, + is_default: this.isDefault, }; } diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts b/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts index cccf84fa..eeb955a6 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/di/payment-methods.di.ts @@ -3,8 +3,12 @@ import type { Sequelize } from "sequelize"; import { type IPaymentMethodPublicFinder, + BootstrapSystemPaymentMethodsService, + BootstrapSystemPaymentMethodsUseCase, PaymentMethodPublicFinder, PaymentMethodPublicModelMapper, + SetDefaultPaymentMethodByIdUseCase, + SetDefaultPaymentMethodService, buildPaymentMethodCreator, buildPaymentMethodDeleter, buildPaymentMethodFinder, @@ -26,6 +30,7 @@ import { import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di"; import { buildPaymentMethodRepository } from "./payment-method-repositories.di"; +import { buildPaymentMethodReferenceUsageChecker } from "../payment-method-reference-usage-checker"; export type PaymentMethodsInternalDeps = { repository: IPaymentMethodRepository; @@ -37,6 +42,8 @@ export type PaymentMethodsInternalDeps = { deletePaymentMethodById: () => DeletePaymentMethodByIdUseCase; disablePaymentMethodById: () => DisablePaymentMethodByIdUseCase; enablePaymentMethodById: () => EnablePaymentMethodByIdUseCase; + setDefaultPaymentMethodById: () => SetDefaultPaymentMethodByIdUseCase; + bootstrapSystemPaymentMethods: () => BootstrapSystemPaymentMethodsUseCase; }; }; @@ -50,14 +57,20 @@ export const buildPaymentMethodsDependencies = ( const persistenceMappers = buildPaymentMethodPersistenceMappers(); const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers }); + const usageChecker = buildPaymentMethodReferenceUsageChecker(database as Sequelize); // Application helpers const inputMappers = buildPaymentMethodInputMappers(); const finder = buildPaymentMethodFinder({ repository }); const creator = buildPaymentMethodCreator({ repository }); const updater = buildPaymentMethodUpdater({ repository }); - const deleter = buildPaymentMethodDeleter({ repository }); + const deleter = buildPaymentMethodDeleter({ repository, usageChecker }); const statusChanger = buildPaymentMethodStatusChanger({ repository }); + const setDefaultService = new SetDefaultPaymentMethodService(repository); + const bootstrapService = new BootstrapSystemPaymentMethodsService({ + repository, + setDefaultService, + }); const snapshotBuilders = buildPaymentMethodSnapshotBuilders(); @@ -110,6 +123,20 @@ export const buildPaymentMethodsDependencies = ( fullSnapshotBuilder: snapshotBuilders.full, transactionManager, }), + + setDefaultPaymentMethodById: () => + new SetDefaultPaymentMethodByIdUseCase({ + setDefaultService, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), + + bootstrapSystemPaymentMethods: () => + new BootstrapSystemPaymentMethodsUseCase({ + bootstrapService, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), }, }; }; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/bootstrap-system-payment-methods.controller.ts b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/bootstrap-system-payment-methods.controller.ts new file mode 100644 index 00000000..b2bd1a1a --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/bootstrap-system-payment-methods.controller.ts @@ -0,0 +1,37 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { BootstrapSystemPaymentMethodsUseCase } from "../../../../application"; +import { paymentMethodsApiErrorMapper } from "../payment-methods-api-error-mapper"; + +export class BootstrapSystemPaymentMethodsController extends ExpressController { + constructor(private readonly useCase: BootstrapSystemPaymentMethodsUseCase) { + super(); + + this.errorMapper = paymentMethodsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const result = await this.useCase.execute({ companyId }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/index.ts b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/index.ts index 118c1fc6..5614f368 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/index.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/index.ts @@ -4,4 +4,6 @@ export * from "./disable-payment-method-by-id.controller"; export * from "./enable-payment-method-by-id.controller"; export * from "./get-payment-method-by-id.controller"; export * from "./list-payment-methods.controller"; +export * from "./bootstrap-system-payment-methods.controller"; +export * from "./set-default-payment-method-by-id.controller"; export * from "./update-payment-method-by-id.controller"; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/set-default-payment-method-by-id.controller.ts b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/set-default-payment-method-by-id.controller.ts new file mode 100644 index 00000000..701c7b94 --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-methods/express/controllers/set-default-payment-method-by-id.controller.ts @@ -0,0 +1,38 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { SetDefaultPaymentMethodByIdUseCase } from "../../../../application"; +import { paymentMethodsApiErrorMapper } from "../payment-methods-api-error-mapper"; + +export class SetDefaultPaymentMethodByIdController extends ExpressController { + constructor(private readonly useCase: SetDefaultPaymentMethodByIdUseCase) { + super(); + + this.errorMapper = paymentMethodsApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { payment_method_id } = this.req.params; + const result = await this.useCase.execute({ payment_method_id, companyId }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods-api-error-mapper.ts b/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods-api-error-mapper.ts index 301e07f7..229c6906 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods-api-error-mapper.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods-api-error-mapper.ts @@ -9,17 +9,21 @@ import { import { type InvalidPaymentMethodIdError, type InvalidPaymentMethodNameError, + type PaymentMethodCannotBeDefaultError, type PaymentMethodCannotBeDeletedError, type PaymentMethodCannotBeDisabledError, type PaymentMethodCannotBeEnabledError, type PaymentMethodCannotBeUpdatedError, + type PaymentMethodInUseError, type PaymentMethodNotFoundError, isInvalidPaymentMethodIdError, isInvalidPaymentMethodNameError, + isPaymentMethodCannotBeDefaultError, isPaymentMethodCannotBeDeletedError, isPaymentMethodCannotBeDisabledError, isPaymentMethodCannotBeEnabledError, isPaymentMethodCannotBeUpdatedError, + isPaymentMethodInUseError, isPaymentMethodNotFoundError, } from "../../../domain/payment-methods"; @@ -60,6 +64,16 @@ const paymentMethodCannotBeDeletedRule: ErrorToApiRule = { ), }; +const paymentMethodCannotBeDefaultRule: ErrorToApiRule = { + priority: 120, + matches: isPaymentMethodCannotBeDefaultError, + build: (error) => + new ValidationApiError( + (error as PaymentMethodCannotBeDefaultError).message || + "Payment method cannot be marked as default." + ), +}; + const paymentMethodCannotBeDisabledRule: ErrorToApiRule = { priority: 120, matches: isPaymentMethodCannotBeDisabledError, @@ -87,11 +101,22 @@ const paymentMethodCannotBeUpdatedRule: ErrorToApiRule = { ), }; +const paymentMethodInUseRule: ErrorToApiRule = { + priority: 120, + matches: isPaymentMethodInUseError, + build: (error) => + new ConflictApiError( + (error as PaymentMethodInUseError).message || "Payment method is currently in use." + ), +}; + export const paymentMethodsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() .register(invalidPaymentMethodIdRule) .register(invalidPaymentMethodNameRule) .register(paymentMethodNotFoundRule) + .register(paymentMethodCannotBeDefaultRule) .register(paymentMethodCannotBeDeletedRule) .register(paymentMethodCannotBeDisabledRule) .register(paymentMethodCannotBeEnabledRule) - .register(paymentMethodCannotBeUpdatedRule); + .register(paymentMethodCannotBeUpdatedRule) + .register(paymentMethodInUseRule); diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods.routes.ts b/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods.routes.ts index 54938905..f0789d74 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods.routes.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/express/payment-methods.routes.ts @@ -3,22 +3,26 @@ import { requireIdentityTenant } from "@erp/identity/api"; import { type NextFunction, type Request, type Response, Router } from "express"; import { + BootstrapSystemPaymentMethodsRequestSchema, CreatePaymentMethodRequestSchema, DeletePaymentMethodByIdRequestSchema, GetPaymentMethodByIdRequestSchema, ListPaymentMethodsRequestSchema, + SetDefaultPaymentMethodByIdRequestSchema, UpdatePaymentMethodByIdParamsRequestSchema, UpdatePaymentMethodByIdRequestSchema, } from "../../../../common"; import type { CatalogsInternalDeps } from "../../di/catalogs.di"; import { + BootstrapSystemPaymentMethodsController, CreatePaymentMethodController, DeletePaymentMethodByIdController, DisablePaymentMethodByIdController, EnablePaymentMethodByIdController, GetPaymentMethodByIdController, ListPaymentMethodsController, + SetDefaultPaymentMethodByIdController, UpdatePaymentMethodByIdController, } from "./controllers"; @@ -43,6 +47,17 @@ export const paymentMethodsRouter = (params: StartParams) => { } ); + router.post( + "/bootstrap-system", + validateRequest(BootstrapSystemPaymentMethodsRequestSchema, "body"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new BootstrapSystemPaymentMethodsController( + deps.useCases.bootstrapSystemPaymentMethods() + ); + return controller.execute(req, res, next); + } + ); + router.post( "/", validateRequest(CreatePaymentMethodRequestSchema, "body"), @@ -84,6 +99,17 @@ export const paymentMethodsRouter = (params: StartParams) => { } ); + router.patch( + "/:payment_method_id/default", + validateRequest(SetDefaultPaymentMethodByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const controller = new SetDefaultPaymentMethodByIdController( + deps.useCases.setDefaultPaymentMethodById() + ); + return controller.execute(req, res, next); + } + ); + router.patch( "/:payment_method_id/disable", validateRequest(GetPaymentMethodByIdRequestSchema, "params"), diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/payment-method-reference-usage-checker.ts b/modules/catalogs/src/api/infrastructure/payment-methods/payment-method-reference-usage-checker.ts new file mode 100644 index 00000000..246f84df --- /dev/null +++ b/modules/catalogs/src/api/infrastructure/payment-methods/payment-method-reference-usage-checker.ts @@ -0,0 +1,83 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; + +import { + PaymentMethodUsageChecker, + type PaymentMethodUsageSource, +} from "../../application/payment-methods/services"; + +type CountableModel = { + count: (params: { + where: Record; + transaction?: Transaction; + }) => Promise; +}; + +type SequelizeModelsRegistry = Record; + +const buildCountUsageSource = + (params: { + database: Sequelize; + modelName: string; + source: PaymentMethodUsageSource; + }) => + async (input: { + companyId: UniqueID; + paymentMethodId: UniqueID; + transaction?: unknown; + }) => { + const { database, modelName, source } = params; + const { companyId, paymentMethodId, transaction } = input; + + try { + const models = database.models as SequelizeModelsRegistry; + const model = models[modelName]; + + if (!model) { + return Result.ok(false); + } + + const count = await model.count({ + where: { + company_id: companyId.toString(), + payment_method_id: paymentMethodId.toString(), + }, + transaction: transaction as Transaction | undefined, + }); + + return Result.ok(count > 0); + } catch (error: unknown) { + return Result.fail( + error instanceof Error + ? error + : new Error(`Failed checking ${source} payment method references.`) + ); + } + }; + +export const buildPaymentMethodReferenceUsageChecker = (database: Sequelize) => { + return new PaymentMethodUsageChecker({ + customersUsageFinder: { + existsByPaymentMethodId: buildCountUsageSource({ + database, + modelName: "CustomerModel", + source: "customers", + }), + }, + proformasUsageFinder: { + existsByPaymentMethodId: buildCountUsageSource({ + database, + modelName: "ProformaModel", + source: "proformas", + }), + }, + supplierInvoicesUsageFinder: { + existsByPaymentMethodId: buildCountUsageSource({ + database, + modelName: "SupplierInvoiceModel", + source: "supplier_invoices", + }), + }, + }); +}; diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-domain.mapper.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-domain.mapper.ts index b81de060..d1dc0125 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-domain.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-domain.mapper.ts @@ -11,7 +11,7 @@ import { } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import { PaymentMethod } from "../../../../../domain"; +import { PaymentMethod, isPaymentMethodSystemKey } from "../../../../../domain"; import type { PaymentMethodCreationAttributes, PaymentMethodModel } from "../models"; export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper< @@ -44,6 +44,15 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper< "description", errors ); + const systemKey = extractOrPushError( + maybeFromNullableResult(source.system_key, (value) => + isPaymentMethodSystemKey(value) + ? Result.ok(value) + : Result.fail(new Error(`Invalid payment method system key: ${value}`)) + ), + "system_key", + errors + ); // Si hubo errores de mapeo, devolvemos colección de validación if (errors.length > 0) { @@ -59,6 +68,8 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper< description: description!, isActive: source.is_active, isSystem: source.is_system, + isDefault: source.is_default, + systemKey: systemKey!, }, idResult.data ); @@ -80,6 +91,11 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper< description: maybeToNullable(source.description, (description) => description.toPrimitive()), is_active: source.isActive, is_system: source.isSystem, + is_default: source.isDefault, + system_key: source.systemKey.match( + (systemKey) => systemKey, + () => null + ), }); } } diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-summary.mapper.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-summary.mapper.ts index 35e66afb..3df9b3f1 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-summary.mapper.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/mappers/sequelize-payment-method-summary.mapper.ts @@ -27,6 +27,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper< const name = extractOrPushError(Name.create(raw.name), "name", errors); const isActive = raw.is_active; const isSystem = raw.is_system; + const isDefault = raw.is_default; // Si hubo errores de mapeo, devolvemos colección de validación if (errors.length > 0) { @@ -41,6 +42,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper< name: name!, isActive: isActive, isSystem: isSystem, + isDefault: isDefault, }); } } diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/models/sequelize-payment-method.model.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/models/sequelize-payment-method.model.ts index f6d3edbb..ae94b909 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/models/sequelize-payment-method.model.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/models/sequelize-payment-method.model.ts @@ -19,6 +19,8 @@ export class PaymentMethodModel extends Model< declare description: CreationOptional; declare is_active: boolean; declare is_system: boolean; + declare is_default: boolean; + declare system_key: CreationOptional; static associate(_database: Sequelize) {} @@ -58,6 +60,16 @@ export default (database: Sequelize) => { allowNull: false, defaultValue: false, }, + is_default: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + system_key: { + type: DataTypes.STRING(40), + allowNull: true, + defaultValue: null, + }, }, { sequelize: database, @@ -79,6 +91,13 @@ export default (database: Sequelize) => { defaultScope: {}, scopes: {}, + indexes: [ + { + name: "uq_payment_methods_company_system_key", + fields: ["company_id", "system_key"], + unique: true, + }, + ], } ); diff --git a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts index 0375ae30..6aded0c8 100644 --- a/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts +++ b/modules/catalogs/src/api/infrastructure/payment-methods/persistence/sequelize/repositories/sequelize-payment-method.repository.ts @@ -11,7 +11,7 @@ import type { Sequelize, Transaction } from "sequelize"; import type { IPaymentMethodRepository } from "../../../../../application"; import type { PaymentMethodSummary } from "../../../../../application/payment-methods/models"; -import type { PaymentMethod } from "../../../../../domain"; +import type { PaymentMethod, PaymentMethodSystemKey } from "../../../../../domain"; import type { SequelizePaymentMethodSummaryMapper } from "../mappers"; import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper"; import { PaymentMethodModel } from "../models"; @@ -174,6 +174,138 @@ export class SequelizePaymentMethodRepository } } + public async findDefaultInCompany( + companyId: UniqueID, + transaction?: Transaction + ): Promise, Error>> { + try { + const row = await PaymentMethodModel.findOne({ + where: { + company_id: companyId.toString(), + is_default: true, + }, + transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + + return Result.ok(Maybe.some(mappedResult.data)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async findBySystemKeyInCompany( + companyId: UniqueID, + systemKey: PaymentMethodSystemKey, + transaction?: Transaction, + options?: { includeDeleted?: boolean } + ): Promise, Error>> { + try { + const row = await PaymentMethodModel.findOne({ + where: { + company_id: companyId.toString(), + system_key: systemKey, + }, + paranoid: !(options?.includeDeleted ?? false), + transaction, + }); + + if (!row) { + return Result.ok(Maybe.none()); + } + + const mappedResult = this.domainMapper.mapToDomain(row); + if (mappedResult.isFailure) { + return Result.fail(mappedResult.error); + } + + return Result.ok(Maybe.some(mappedResult.data)); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async restoreByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction?: Transaction + ): Promise> { + try { + const restored = await PaymentMethodModel.restore({ + where: { + id: id.toString(), + company_id: companyId.toString(), + }, + transaction, + }); + + return Result.ok(restored > 0); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async syncSystemPaymentMethodState(params: { + companyId: UniqueID; + id: UniqueID; + name: string; + isDefault: boolean; + systemKey: PaymentMethodSystemKey; + transaction?: Transaction; + }): Promise> { + try { + await PaymentMethodModel.update( + { + name: params.name, + is_active: true, + is_system: true, + is_default: params.isDefault, + system_key: params.systemKey, + }, + { + where: { + id: params.id.toString(), + company_id: params.companyId.toString(), + }, + paranoid: false, + transaction: params.transaction, + } + ); + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + + public async lockByCompanyId( + companyId: UniqueID, + transaction?: Transaction + ): Promise> { + try { + await PaymentMethodModel.findAll({ + attributes: ["id"], + where: { + company_id: companyId.toString(), + }, + transaction, + lock: transaction ? transaction.LOCK.UPDATE : undefined, + }); + + return Result.ok(); + } catch (error: unknown) { + return Result.fail(translateSequelizeError(error)); + } + } + /** * Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.). * diff --git a/modules/catalogs/src/common/dto/payment-methods/request/bootstrap-system-payment-methods.request.dto.ts b/modules/catalogs/src/common/dto/payment-methods/request/bootstrap-system-payment-methods.request.dto.ts new file mode 100644 index 00000000..34026b1b --- /dev/null +++ b/modules/catalogs/src/common/dto/payment-methods/request/bootstrap-system-payment-methods.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const BootstrapSystemPaymentMethodsRequestSchema = z.object({}); + +export type BootstrapSystemPaymentMethodsRequestDTO = z.infer< + typeof BootstrapSystemPaymentMethodsRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/payment-methods/request/index.ts b/modules/catalogs/src/common/dto/payment-methods/request/index.ts index 419ca9fb..0e5779a7 100644 --- a/modules/catalogs/src/common/dto/payment-methods/request/index.ts +++ b/modules/catalogs/src/common/dto/payment-methods/request/index.ts @@ -1,5 +1,7 @@ export * from "./create-payment-method.request.dto"; +export * from "./bootstrap-system-payment-methods.request.dto"; export * from "./delete-payment-method-by-id.request.dto"; export * from "./get-payment-method-by-id.request.dto"; export * from "./list-payment-methods.request.dto"; +export * from "./set-default-payment-method-by-id.request.dto"; export * from "./update-payment-method-by-id.request.dto"; diff --git a/modules/catalogs/src/common/dto/payment-methods/request/set-default-payment-method-by-id.request.dto.ts b/modules/catalogs/src/common/dto/payment-methods/request/set-default-payment-method-by-id.request.dto.ts new file mode 100644 index 00000000..b650e542 --- /dev/null +++ b/modules/catalogs/src/common/dto/payment-methods/request/set-default-payment-method-by-id.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const SetDefaultPaymentMethodByIdRequestSchema = z.object({ + payment_method_id: z.uuid(), +}); + +export type SetDefaultPaymentMethodByIdRequestDTO = z.infer< + typeof SetDefaultPaymentMethodByIdRequestSchema +>; diff --git a/modules/catalogs/src/common/dto/payment-methods/response/bootstrap-system-payment-methods.response.dto.ts b/modules/catalogs/src/common/dto/payment-methods/response/bootstrap-system-payment-methods.response.dto.ts new file mode 100644 index 00000000..c914516e --- /dev/null +++ b/modules/catalogs/src/common/dto/payment-methods/response/bootstrap-system-payment-methods.response.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod/v4"; + +import { PaymentMethodDetailSchema } from "../shared"; + +export const BootstrapSystemPaymentMethodsResponseSchema = z.object({ + items: z.array(PaymentMethodDetailSchema), +}); + +export type BootstrapSystemPaymentMethodsResponseDTO = z.infer< + typeof BootstrapSystemPaymentMethodsResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/payment-methods/response/index.ts b/modules/catalogs/src/common/dto/payment-methods/response/index.ts index 82bc3306..52bb2443 100644 --- a/modules/catalogs/src/common/dto/payment-methods/response/index.ts +++ b/modules/catalogs/src/common/dto/payment-methods/response/index.ts @@ -1,5 +1,7 @@ export * from "./create-payment-method.response.dto"; +export * from "./bootstrap-system-payment-methods.response.dto"; export * from "./disable-payment-method-by-id.response.dto"; export * from "./get-payment-method-by-id.response.dto"; export * from "./list-payment-methods.response.dto"; +export * from "./set-default-payment-method-by-id.response.dto"; export * from "./update-payment-method-by-id.response.dto"; diff --git a/modules/catalogs/src/common/dto/payment-methods/response/set-default-payment-method-by-id.response.dto.ts b/modules/catalogs/src/common/dto/payment-methods/response/set-default-payment-method-by-id.response.dto.ts new file mode 100644 index 00000000..217ae2ee --- /dev/null +++ b/modules/catalogs/src/common/dto/payment-methods/response/set-default-payment-method-by-id.response.dto.ts @@ -0,0 +1,8 @@ +import type { z } from "zod/v4"; + +import { PaymentMethodDetailSchema } from "../shared"; + +export const SetDefaultPaymentMethodByIdResponseSchema = PaymentMethodDetailSchema; +export type SetDefaultPaymentMethodByIdResponseDTO = z.infer< + typeof SetDefaultPaymentMethodByIdResponseSchema +>; diff --git a/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-detail.dto.ts b/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-detail.dto.ts index f73cb408..af49d3e2 100644 --- a/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-detail.dto.ts +++ b/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-detail.dto.ts @@ -8,6 +8,7 @@ export const PaymentMethodDetailSchema = z.object({ description: z.string().nullable(), is_active: z.boolean(), is_system: z.boolean(), + is_default: z.boolean(), }); export type PaymentMethodDetailDTO = z.infer; diff --git a/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-summary.dto.ts b/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-summary.dto.ts index 179a41bf..8702c9c1 100644 --- a/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-summary.dto.ts +++ b/modules/catalogs/src/common/dto/payment-methods/shared/payment-method-summary.dto.ts @@ -7,6 +7,7 @@ export const PaymentMethodSummarySchema = z.object({ name: z.string(), is_active: z.boolean(), is_system: z.boolean(), + is_default: z.boolean(), }); export type PaymentMethodSummaryDTO = z.infer; diff --git a/modules/catalogs/src/common/locales/en.json b/modules/catalogs/src/common/locales/en.json new file mode 100644 index 00000000..1a2218cf --- /dev/null +++ b/modules/catalogs/src/common/locales/en.json @@ -0,0 +1,116 @@ +{ + "settings": { + "paymentMethods": { + "title": "Payment methods", + "description": "Manage the payment methods available for the company.", + "default": { + "label": "Default payment method", + "rowLabel": "Set {{name}} as the default payment method" + }, + "fields": { + "name": "Name", + "nameDescription": "Visible payment method name.", + "namePlaceholder": "e.g. Bizum", + "description": "Description", + "descriptionDescription": "Optional internal information.", + "descriptionPlaceholder": "Add an optional description", + "active": "Active", + "activeDescription": "Controls whether the method is available." + }, + "actions": { + "create": "New method", + "creating": "Creating…", + "edit": "Edit", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "deleting": "Deleting…", + "more": "More actions" + }, + "status": { + "active": "Active", + "inactive": "Inactive", + "system": "System", + "default": "Default" + }, + "create": { + "title": "New payment method", + "description": "Create a new payment method for this company." + }, + "edit": { + "title": "Edit payment method", + "description": "Update the selected payment method information." + }, + "delete": { + "title": "Delete payment method", + "description": "This method will stop being available. If it is still in use, the operation will not be completed.", + "errorTitle": "The payment method could not be deleted", + "inUse": "This payment method cannot be deleted because it is being used." + }, + "empty": { + "title": "There are no payment methods configured.", + "description": "Available payment methods will appear here." + }, + "messages": { + "createdTitle": "Payment method created", + "created": "The payment method was created successfully.", + "updatedTitle": "Payment method updated", + "updated": "Changes were saved successfully.", + "enabledTitle": "Payment method enabled", + "enabled": "The method is available again.", + "disabledTitle": "Payment method disabled", + "disabled": "The method is no longer available.", + "defaultChangedTitle": "Default updated", + "defaultChanged": "The default payment method was updated.", + "deletedTitle": "Payment method deleted", + "deleted": "The payment method is no longer available.", + "validationTitle": "Review the fields", + "validationDescription": "There are validation errors in the form." + }, + "protections": { + "systemEdit": "System methods cannot be edited.", + "systemDisable": "System methods cannot be disabled.", + "systemDelete": "System methods cannot be deleted.", + "defaultDisable": "The default payment method cannot be disabled.", + "defaultDelete": "The default payment method cannot be deleted." + }, + "pageError": { + "actions": { + "retry": "Retry", + "retrying": "Retrying…" + }, + "forbidden": { + "title": "You do not have access to this setting", + "description": "You do not have permission to view payment methods." + }, + "notFound": { + "title": "The settings could not be loaded", + "description": "The requested settings are no longer available." + }, + "network": { + "title": "Could not connect to the server", + "description": "Check your connection and try again." + }, + "timeout": { + "title": "Loading is taking too long", + "description": "The server did not respond in time. Try again." + }, + "unavailable": { + "title": "Service unavailable", + "description": "The settings are not available right now." + }, + "server": { + "title": "The settings could not be loaded", + "description": "An internal error occurred while loading payment methods." + }, + "unknown": { + "title": "The settings could not be loaded", + "description": "An unexpected error occurred while loading payment methods." + } + } + } + } +} diff --git a/modules/catalogs/src/common/locales/es.json b/modules/catalogs/src/common/locales/es.json new file mode 100644 index 00000000..41a40566 --- /dev/null +++ b/modules/catalogs/src/common/locales/es.json @@ -0,0 +1,116 @@ +{ + "settings": { + "paymentMethods": { + "title": "Métodos de pago", + "description": "Gestiona las formas de pago disponibles para la empresa.", + "default": { + "label": "Método predeterminado", + "rowLabel": "Establecer {{name}} como método predeterminado" + }, + "fields": { + "name": "Nombre", + "nameDescription": "Nombre visible del método de pago.", + "namePlaceholder": "Ej. Bizum", + "description": "Descripción", + "descriptionDescription": "Información adicional opcional para uso interno.", + "descriptionPlaceholder": "Añade una descripción opcional", + "active": "Activo", + "activeDescription": "Controla si el método aparece como disponible." + }, + "actions": { + "create": "Nuevo método", + "creating": "Creando…", + "edit": "Editar", + "save": "Guardar", + "saving": "Guardando…", + "cancel": "Cancelar", + "enable": "Activar", + "disable": "Desactivar", + "delete": "Eliminar", + "deleting": "Eliminando…", + "more": "Más acciones" + }, + "status": { + "active": "Activo", + "inactive": "Inactivo", + "system": "Sistema", + "default": "Predeterminado" + }, + "create": { + "title": "Nuevo método de pago", + "description": "Crea una nueva forma de pago para esta empresa." + }, + "edit": { + "title": "Editar método de pago", + "description": "Actualiza la información del método de pago seleccionado." + }, + "delete": { + "title": "Eliminar método de pago", + "description": "Este método dejará de estar disponible. Si está siendo utilizado, la operación no se completará.", + "errorTitle": "No se pudo eliminar el método de pago", + "inUse": "No se puede eliminar este método de pago porque está siendo utilizado." + }, + "empty": { + "title": "No hay métodos de pago configurados.", + "description": "Cuando existan métodos de pago disponibles aparecerán aquí." + }, + "messages": { + "createdTitle": "Método de pago creado", + "created": "El método de pago se ha creado correctamente.", + "updatedTitle": "Método de pago actualizado", + "updated": "Los cambios se han guardado correctamente.", + "enabledTitle": "Método de pago activado", + "enabled": "El método vuelve a estar disponible.", + "disabledTitle": "Método de pago desactivado", + "disabled": "El método ha dejado de estar disponible.", + "defaultChangedTitle": "Predeterminado actualizado", + "defaultChanged": "Se ha actualizado el método de pago predeterminado.", + "deletedTitle": "Método de pago eliminado", + "deleted": "El método de pago ha dejado de estar disponible.", + "validationTitle": "Revisa los campos", + "validationDescription": "Hay errores de validación en el formulario." + }, + "protections": { + "systemEdit": "Los métodos del sistema no se pueden editar.", + "systemDisable": "Los métodos del sistema no se pueden desactivar.", + "systemDelete": "Los métodos del sistema no se pueden eliminar.", + "defaultDisable": "El método predeterminado no se puede desactivar.", + "defaultDelete": "El método predeterminado no se puede eliminar." + }, + "pageError": { + "actions": { + "retry": "Reintentar", + "retrying": "Reintentando…" + }, + "forbidden": { + "title": "No tienes acceso a esta configuración", + "description": "No dispones de permisos para consultar los métodos de pago." + }, + "notFound": { + "title": "No se pudo cargar la configuración", + "description": "La configuración solicitada ya no está disponible." + }, + "network": { + "title": "No se pudo conectar con el servidor", + "description": "Comprueba tu conexión e inténtalo de nuevo." + }, + "timeout": { + "title": "La carga está tardando demasiado", + "description": "El servidor no respondió a tiempo. Inténtalo de nuevo." + }, + "unavailable": { + "title": "Servicio no disponible", + "description": "La configuración no está disponible en este momento." + }, + "server": { + "title": "No se pudo cargar la configuración", + "description": "Se produjo un error interno al obtener los métodos de pago." + }, + "unknown": { + "title": "No se pudo cargar la configuración", + "description": "Se produjo un error inesperado al obtener los métodos de pago." + } + } + } + } +} diff --git a/modules/catalogs/src/web/catalogs-routes.tsx b/modules/catalogs/src/web/catalogs-routes.tsx new file mode 100644 index 00000000..b83eaef4 --- /dev/null +++ b/modules/catalogs/src/web/catalogs-routes.tsx @@ -0,0 +1,22 @@ +import { lazy } from "react"; + +import type { ModuleClientParams, ModuleRouteObject } from "@erp/core/client"; + +const PaymentMethodsSettingsPage = lazy(() => + import("./settings/payment-methods").then((module) => ({ + default: module.PaymentMethodsSettingsPage, + })) +); + +export const CatalogsRoutes = (_params: ModuleClientParams): ModuleRouteObject[] => { + return [ + { + path: "settings/general/payment-methods", + handle: { + layout: "app-sidebar", + protected: true, + }, + element: , + }, + ]; +}; diff --git a/modules/catalogs/src/web/i18n.ts b/modules/catalogs/src/web/i18n.ts new file mode 100644 index 00000000..912e3eee --- /dev/null +++ b/modules/catalogs/src/web/i18n.ts @@ -0,0 +1,35 @@ +import { i18n, registerTranslations } from "@repo/i18next"; +import { useEffect } from "react"; +import { useTranslation as useI18NextTranslation } from "react-i18next"; + +import enResources from "../common/locales/en.json"; +import esResources from "../common/locales/es.json"; + +import { MODULE_NAME } from "./manifest"; + +/** + * Registra dinámicamente las traducciones del módulo. + */ +export const ensureModuleTranslations = () => { + registerTranslations(MODULE_NAME, "en", enResources); + registerTranslations(MODULE_NAME, "es", esResources); +}; + +/** + * Hook de traducción del módulo, version adaptada. + * + * - Asegura los bundles del módulo. + * - Devuelve el hook react-i18next con namespace. + */ +export const useTranslation = () => { + // Asegura los bundles del módulo al montar (idempotente) + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + ensureModuleTranslations(); + }, [i18n]); + + // Hook con namespace + return useI18NextTranslation(MODULE_NAME, { + i18n, + }); +}; diff --git a/modules/catalogs/src/web/manifest.test.ts b/modules/catalogs/src/web/manifest.test.ts new file mode 100644 index 00000000..792dd007 --- /dev/null +++ b/modules/catalogs/src/web/manifest.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CatalogsModuleManifest } from "./manifest"; + +test("publica la entry de Settings para payment methods", () => { + assert.deepEqual(CatalogsModuleManifest.settings, { + entries: [ + { + id: "payment-methods", + sectionId: "general", + labelKey: "settings.paymentMethods.title", + path: "/settings/general/payment-methods", + }, + ], + }); +}); + +test("expone una ruta modular para settings/general/payment-methods", () => { + const routes = CatalogsModuleManifest.routes?.({}); + const settingsRoute = routes?.find((route) => route.path === "settings/general/payment-methods"); + + assert.ok(settingsRoute); + assert.equal(settingsRoute?.handle?.layout, "app-sidebar"); + assert.equal(settingsRoute?.handle?.protected, true); +}); diff --git a/modules/catalogs/src/web/manifest.ts b/modules/catalogs/src/web/manifest.ts index 6fa23e74..d9d36987 100644 --- a/modules/catalogs/src/web/manifest.ts +++ b/modules/catalogs/src/web/manifest.ts @@ -1,18 +1,30 @@ -import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; +import type { IModuleClient } from "@erp/core/client"; + +import { CatalogsRoutes } from "./catalogs-routes"; +import { ensureModuleTranslations } from "./i18n"; export const MODULE_NAME = "Catalogs"; const MODULE_VERSION = "1.0.0"; +ensureModuleTranslations(); + export const CatalogsModuleManifest: IModuleClient = { name: MODULE_NAME, version: MODULE_VERSION, dependencies: ["auth", "Core"], protected: true, layout: "app-sidebar", - - routes: (params: ModuleClientParams) => { - return []; + settings: { + entries: [ + { + id: "payment-methods", + sectionId: "general", + labelKey: "settings.paymentMethods.title", + path: "/settings/general/payment-methods", + }, + ], }, + routes: (params) => CatalogsRoutes(params), }; export default CatalogsModuleManifest; diff --git a/modules/catalogs/src/web/payment-methods/shared/adapters/get-payment-method-by-id.adapter.ts b/modules/catalogs/src/web/payment-methods/shared/adapters/get-payment-method-by-id.adapter.ts new file mode 100644 index 00000000..9ab78901 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/adapters/get-payment-method-by-id.adapter.ts @@ -0,0 +1,16 @@ +import type { PaymentMethodDetailDTO } from "../../../../common"; +import type { PaymentMethod } from "../entities"; + +export const GetPaymentMethodByIdAdapter = { + fromDto(dto: PaymentMethodDetailDTO): PaymentMethod { + return { + id: dto.id, + companyId: dto.company_id, + name: dto.name, + description: dto.description, + isActive: dto.is_active, + isSystem: dto.is_system, + isDefault: dto.is_default, + }; + }, +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/adapters/index.ts b/modules/catalogs/src/web/payment-methods/shared/adapters/index.ts index 24346c83..34283ba4 100644 --- a/modules/catalogs/src/web/payment-methods/shared/adapters/index.ts +++ b/modules/catalogs/src/web/payment-methods/shared/adapters/index.ts @@ -1 +1,2 @@ -export * from './list-payment-methods.adapter'; +export * from "./list-payment-methods.adapter"; +export * from "./get-payment-method-by-id.adapter"; diff --git a/modules/catalogs/src/web/payment-methods/shared/adapters/list-payment-methods.adapter.ts b/modules/catalogs/src/web/payment-methods/shared/adapters/list-payment-methods.adapter.ts index 24560b31..e3281c11 100644 --- a/modules/catalogs/src/web/payment-methods/shared/adapters/list-payment-methods.adapter.ts +++ b/modules/catalogs/src/web/payment-methods/shared/adapters/list-payment-methods.adapter.ts @@ -15,13 +15,13 @@ import type { PaymentMethodList, PaymentMethodListRow } from "../entities"; */ export const ListPaymentMethodsAdapter = { - fromDto(dto: ListPaymentMethodsResult, context?: unknown): PaymentMethodList { + fromDto(dto: ListPaymentMethodsResult): PaymentMethodList { return { page: dto.page, perPage: dto.per_page, totalPages: dto.total_pages, totalItems: dto.total_items, - items: dto.items.map((rowDto) => PaymentMehodListRowAdapter.fromDto(rowDto, context)), + items: dto.items.map((rowDto) => PaymentMehodListRowAdapter.fromDto(rowDto)), }; }, }; @@ -39,7 +39,7 @@ export const ListPaymentMethodsAdapter = { type ListPaymentMethodsItemOutput = ListPaymentMethodsResponseDTO["items"][number]; const PaymentMehodListRowAdapter = { - fromDto(dto: ListPaymentMethodsItemOutput, context?: unknown): PaymentMethodListRow { + fromDto(dto: ListPaymentMethodsItemOutput): PaymentMethodListRow { return { id: dto.id, companyId: dto.company_id, @@ -48,6 +48,7 @@ const PaymentMehodListRowAdapter = { isSystem: dto.is_system, isActive: dto.is_active, + isDefault: dto.is_default, }; }, }; diff --git a/modules/catalogs/src/web/payment-methods/shared/api/create-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/create-payment-method.api.ts new file mode 100644 index 00000000..7e086bd0 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/create-payment-method.api.ts @@ -0,0 +1,18 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { CreatePaymentMethodRequestDTO, CreatePaymentMethodResponseDTO } from "../../../../common"; + +export interface CreatePaymentMethodParams { + id: string; + data: CreatePaymentMethodRequestDTO; +} + +export function createPaymentMethod( + dataSource: IDataSource, + params: CreatePaymentMethodParams +): Promise { + return dataSource.createOne( + "catalogs/payment-methods", + params.data + ); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/delete-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/delete-payment-method.api.ts new file mode 100644 index 00000000..0d58f962 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/delete-payment-method.api.ts @@ -0,0 +1,9 @@ +import type { IDataSource } from "@erp/core/client"; + +export interface DeletePaymentMethodParams { + paymentMethodId: string; +} + +export function deletePaymentMethod(dataSource: IDataSource, params: DeletePaymentMethodParams) { + return dataSource.deleteOne("catalogs/payment-methods", params.paymentMethodId); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/disable-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/disable-payment-method.api.ts new file mode 100644 index 00000000..e4e7ac9a --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/disable-payment-method.api.ts @@ -0,0 +1,18 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { DisablePaymentMethodByIdResponseDTO } from "../../../../common"; + +export interface DisablePaymentMethodParams { + paymentMethodId: string; +} + +export function disablePaymentMethod( + dataSource: IDataSource, + params: DisablePaymentMethodParams +): Promise { + return dataSource.custom({ + method: "patch", + path: `catalogs/payment-methods/${params.paymentMethodId}/disable`, + data: undefined, + }); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/enable-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/enable-payment-method.api.ts new file mode 100644 index 00000000..857330c7 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/enable-payment-method.api.ts @@ -0,0 +1,18 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { UpdatePaymentMethodByIdResponseDTO } from "../../../../common"; + +export interface EnablePaymentMethodParams { + paymentMethodId: string; +} + +export function enablePaymentMethod( + dataSource: IDataSource, + params: EnablePaymentMethodParams +): Promise { + return dataSource.custom({ + method: "patch", + path: `catalogs/payment-methods/${params.paymentMethodId}/enable`, + data: undefined, + }); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/get-payment-method-by-id.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/get-payment-method-by-id.api.ts new file mode 100644 index 00000000..b0f472f1 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/get-payment-method-by-id.api.ts @@ -0,0 +1,21 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { GetPaymentMethodByIdResponseDTO } from "../../../../common"; + +export interface GetPaymentMethodByIdParams { + paymentMethodId: string; + signal?: AbortSignal; +} + +export function getPaymentMethodById( + dataSource: IDataSource, + params: GetPaymentMethodByIdParams +): Promise { + return dataSource.getOne( + "catalogs/payment-methods", + params.paymentMethodId, + { + signal: params.signal, + } + ); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/index.ts b/modules/catalogs/src/web/payment-methods/shared/api/index.ts index c865736b..b58193f8 100644 --- a/modules/catalogs/src/web/payment-methods/shared/api/index.ts +++ b/modules/catalogs/src/web/payment-methods/shared/api/index.ts @@ -1 +1,8 @@ export * from "./list-payment-methods-by-criteria.api"; +export * from "./get-payment-method-by-id.api"; +export * from "./create-payment-method.api"; +export * from "./update-payment-method.api"; +export * from "./delete-payment-method.api"; +export * from "./enable-payment-method.api"; +export * from "./disable-payment-method.api"; +export * from "./set-default-payment-method.api"; diff --git a/modules/catalogs/src/web/payment-methods/shared/api/set-default-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/set-default-payment-method.api.ts new file mode 100644 index 00000000..1cff1e2e --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/set-default-payment-method.api.ts @@ -0,0 +1,18 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { SetDefaultPaymentMethodByIdResponseDTO } from "../../../../common"; + +export interface SetDefaultPaymentMethodParams { + paymentMethodId: string; +} + +export function setDefaultPaymentMethod( + dataSource: IDataSource, + params: SetDefaultPaymentMethodParams +): Promise { + return dataSource.custom({ + method: "patch", + path: `catalogs/payment-methods/${params.paymentMethodId}/default`, + data: undefined, + }); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/api/update-payment-method.api.ts b/modules/catalogs/src/web/payment-methods/shared/api/update-payment-method.api.ts new file mode 100644 index 00000000..031ea13a --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/api/update-payment-method.api.ts @@ -0,0 +1,19 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { UpdatePaymentMethodByIdRequestDTO, UpdatePaymentMethodByIdResponseDTO } from "../../../../common"; + +export interface UpdatePaymentMethodParams { + paymentMethodId: string; + data: UpdatePaymentMethodByIdRequestDTO; +} + +export function updatePaymentMethod( + dataSource: IDataSource, + params: UpdatePaymentMethodParams +): Promise { + return dataSource.updateOne( + "catalogs/payment-methods", + params.paymentMethodId, + params.data + ); +} diff --git a/modules/catalogs/src/web/payment-methods/shared/entities/payment-method-list-row.entity.ts b/modules/catalogs/src/web/payment-methods/shared/entities/payment-method-list-row.entity.ts index cfe63a8d..a390d3ea 100644 --- a/modules/catalogs/src/web/payment-methods/shared/entities/payment-method-list-row.entity.ts +++ b/modules/catalogs/src/web/payment-methods/shared/entities/payment-method-list-row.entity.ts @@ -13,4 +13,5 @@ export interface PaymentMethodListRow { isSystem: boolean; isActive: boolean; + isDefault: boolean; } diff --git a/modules/catalogs/src/web/payment-methods/shared/entities/payment-method.entity.ts b/modules/catalogs/src/web/payment-methods/shared/entities/payment-method.entity.ts index c89eb030..47487d4d 100644 --- a/modules/catalogs/src/web/payment-methods/shared/entities/payment-method.entity.ts +++ b/modules/catalogs/src/web/payment-methods/shared/entities/payment-method.entity.ts @@ -7,4 +7,5 @@ export interface PaymentMethod { isSystem: boolean; isActive: boolean; + isDefault: boolean; } diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/index.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/index.ts index 60b85610..e6a8b294 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/index.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/index.ts @@ -1 +1,8 @@ export * from "./use-payment-method-list-query"; +export * from "./use-payment-method-detail-query"; +export * from "./use-payment-method-create-mutation"; +export * from "./use-payment-method-update-mutation"; +export * from "./use-payment-method-delete-mutation"; +export * from "./use-payment-method-enable-mutation"; +export * from "./use-payment-method-disable-mutation"; +export * from "./use-payment-method-set-default-mutation"; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/keys.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/keys.ts index 2f1cb914..e3405486 100644 --- a/modules/catalogs/src/web/payment-methods/shared/hooks/keys.ts +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/keys.ts @@ -1,21 +1,15 @@ +import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; import type { QueryKey } from "@tanstack/react-query"; import type { ListPaymentMethodsRequestDTO } from "../../../../common"; -/** - * Prefijo base para listados - */ export const LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX = ["payment-methods"] as const; - -/** - * Query key para listado de payment methods - */ export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequestDTO): QueryKey => [ ...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, { - pageNumber: criteria?.pageNumber ?? 1, - pageSize: criteria?.pageSize ?? 5, + pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX, + pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE, q: criteria?.q ?? "", filters: criteria?.filters ?? [], orderBy: criteria?.orderBy ?? "", @@ -23,22 +17,21 @@ export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequ }, ] as const; -/** - * Query key para detalle de payment method - */ export const PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX = ["payment-methods:detail"] as const; export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [ ...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX, { paymentMethodId }, ]; -/** - * Keys para mutaciones - */ export const CREATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:create"] as const; export const UPDATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:update"] as const; export const DELETE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:delete"] as const; +export const ENABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:enable"] as const; +export const DISABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:disable"] as const; +export const SET_DEFAULT_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:set-default"] as const; -/** - * Operaciones de dominio - */ +export const paymentMethodKeys = { + all: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + list: (criteria?: ListPaymentMethodsRequestDTO) => LIST_PAYMENT_METHODS_QUERY_KEY(criteria), + detail: (paymentMethodId?: string) => PAYMENT_METHOD_QUERY_KEY(paymentMethodId), +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/to-validation-errors.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/to-validation-errors.ts new file mode 100644 index 00000000..43e54858 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/to-validation-errors.ts @@ -0,0 +1,9 @@ +import type { ValidationErrorDetail } from "@repo/rdx-ddd"; +import type { ZodError } from "zod"; + +export const toValidationErrors = (error: ZodError): ValidationErrorDetail[] => + error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + value: issue.input, + })); diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts new file mode 100644 index 00000000..23fa9bd2 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-create-mutation.ts @@ -0,0 +1,44 @@ +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useDataSource } from "@erp/core/hooks"; + +import { CreatePaymentMethodRequestSchema } from "../../../../common"; +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { type CreatePaymentMethodParams, createPaymentMethod } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { + CREATE_PAYMENT_METHOD_MUTATION_KEY, + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHOD_QUERY_KEY, +} from "./keys"; +import { toValidationErrors } from "./to-validation-errors"; + +export const usePaymentMethodCreateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: CREATE_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + if (!params.id) { + throw new Error("paymentMethodId is required"); + } + + const result = CreatePaymentMethodRequestSchema.safeParse(params.data); + + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + const dto = await createPaymentMethod(dataSource, params); + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + onSuccess: async (paymentMethod) => { + queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + await queryClient.invalidateQueries({ + queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts new file mode 100644 index 00000000..5e77be74 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-delete-mutation.ts @@ -0,0 +1,28 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { deletePaymentMethod, type DeletePaymentMethodParams } from "../api"; + +import { + DELETE_PAYMENT_METHOD_MUTATION_KEY, + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX, +} from "./keys"; + +export const usePaymentMethodDeleteMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: DELETE_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + await deletePaymentMethod(dataSource, params); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }), + queryClient.invalidateQueries({ queryKey: PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX }), + ]); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts new file mode 100644 index 00000000..92ca7a7d --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-detail-query.ts @@ -0,0 +1,29 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { getPaymentMethodById } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { PAYMENT_METHOD_QUERY_KEY } from "./keys"; + +export const usePaymentMethodDetailQuery = ( + paymentMethodId?: string, + options?: { enabled?: boolean } +): UseQueryResult => { + const dataSource = useDataSource(); + const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId); + + return useQuery({ + queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId), + enabled, + queryFn: async ({ signal }) => { + const dto = await getPaymentMethodById(dataSource, { + paymentMethodId: paymentMethodId as string, + signal, + }); + + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts new file mode 100644 index 00000000..8e520a4d --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-disable-mutation.ts @@ -0,0 +1,29 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { disablePaymentMethod, type DisablePaymentMethodParams } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { + DISABLE_PAYMENT_METHOD_MUTATION_KEY, + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHOD_QUERY_KEY, +} from "./keys"; + +export const usePaymentMethodDisableMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: DISABLE_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + const dto = await disablePaymentMethod(dataSource, params); + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + onSuccess: async (paymentMethod) => { + queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts new file mode 100644 index 00000000..af4b251c --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-enable-mutation.ts @@ -0,0 +1,29 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { enablePaymentMethod, type EnablePaymentMethodParams } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { + ENABLE_PAYMENT_METHOD_MUTATION_KEY, + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHOD_QUERY_KEY, +} from "./keys"; + +export const usePaymentMethodEnableMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: ENABLE_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + const dto = await enablePaymentMethod(dataSource, params); + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + onSuccess: async (paymentMethod) => { + queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + await queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts new file mode 100644 index 00000000..8b1d1544 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-set-default-mutation.ts @@ -0,0 +1,32 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; + +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { setDefaultPaymentMethod, type SetDefaultPaymentMethodParams } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHOD_QUERY_KEY, + SET_DEFAULT_PAYMENT_METHOD_MUTATION_KEY, +} from "./keys"; + +export const usePaymentMethodSetDefaultMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: SET_DEFAULT_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + const dto = await setDefaultPaymentMethod(dataSource, params); + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + onSuccess: async (paymentMethod) => { + queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX }), + queryClient.invalidateQueries({ queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethod.id) }), + ]); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts new file mode 100644 index 00000000..313bfda7 --- /dev/null +++ b/modules/catalogs/src/web/payment-methods/shared/hooks/use-payment-method-update-mutation.ts @@ -0,0 +1,44 @@ +import { ValidationErrorCollection } from "@repo/rdx-ddd"; +import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useDataSource } from "@erp/core/hooks"; + +import { UpdatePaymentMethodByIdRequestSchema } from "../../../../common"; +import { GetPaymentMethodByIdAdapter } from "../adapters"; +import { type UpdatePaymentMethodParams, updatePaymentMethod } from "../api"; +import type { PaymentMethod } from "../entities"; + +import { + LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + PAYMENT_METHOD_QUERY_KEY, + UPDATE_PAYMENT_METHOD_MUTATION_KEY, +} from "./keys"; +import { toValidationErrors } from "./to-validation-errors"; + +export const usePaymentMethodUpdateMutation = () => { + const queryClient = useQueryClient(); + const dataSource = useDataSource(); + + return useMutation({ + mutationKey: UPDATE_PAYMENT_METHOD_MUTATION_KEY, + mutationFn: async (params) => { + if (!params.paymentMethodId) { + throw new Error("paymentMethodId is required"); + } + + const result = UpdatePaymentMethodByIdRequestSchema.safeParse(params.data); + + if (!result.success) { + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); + } + + const dto = await updatePaymentMethod(dataSource, params); + return GetPaymentMethodByIdAdapter.fromDto(dto); + }, + onSuccess: async (paymentMethod) => { + queryClient.setQueryData(PAYMENT_METHOD_QUERY_KEY(paymentMethod.id), paymentMethod); + await queryClient.invalidateQueries({ + queryKey: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, + }); + }, + }); +}; diff --git a/modules/catalogs/src/web/payment-methods/shared/index.ts b/modules/catalogs/src/web/payment-methods/shared/index.ts index 3a4614c9..d2bc2683 100644 --- a/modules/catalogs/src/web/payment-methods/shared/index.ts +++ b/modules/catalogs/src/web/payment-methods/shared/index.ts @@ -1,2 +1,4 @@ export * from "./entities"; export * from "./hooks"; +export * from "./api"; +export * from "./adapters"; diff --git a/modules/catalogs/src/web/settings/index.ts b/modules/catalogs/src/web/settings/index.ts new file mode 100644 index 00000000..cd29a5d4 --- /dev/null +++ b/modules/catalogs/src/web/settings/index.ts @@ -0,0 +1 @@ +export * from "./payment-methods"; diff --git a/modules/catalogs/src/web/settings/payment-methods/controllers/index.ts b/modules/catalogs/src/web/settings/payment-methods/controllers/index.ts new file mode 100644 index 00000000..7617d1d1 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./use-payment-methods-settings-controller"; +export * from "./use-payment-methods-settings-page-controller"; diff --git a/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-controller.ts b/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-controller.ts new file mode 100644 index 00000000..9336bb61 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-controller.ts @@ -0,0 +1,323 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { getUserErrorMessage, isApiClientError } from "@erp/core/client"; +import { useHookForm } from "@erp/core/hooks"; +import { UniqueID, ValidationErrorCollection, isValidationErrorCollection } from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { useCallback, useEffect, useId, useState } from "react"; +import type { FormEvent } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../../i18n"; +import { + usePaymentMethodCreateMutation, + usePaymentMethodDeleteMutation, + usePaymentMethodDetailQuery, + usePaymentMethodDisableMutation, + usePaymentMethodEnableMutation, + usePaymentMethodSetDefaultMutation, + usePaymentMethodUpdateMutation, + type PaymentMethodListRow, +} from "../../../payment-methods/shared"; +import type { PaymentMethodForm } from "../entities"; +import { PaymentMethodFormSchema } from "../entities"; +import { buildPaymentMethodFormDefault, buildPaymentMethodFormFromEntity } from "../utils"; +import { + buildCreatePaymentMethodParams, + buildUpdatePaymentMethodParams, +} from "../utils/build-payment-method-params"; + +const normalizeFormSubmitError = (error: unknown): Error | ValidationErrorCollection => { + if (isValidationErrorCollection(error)) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error("Unknown error"); +}; + +export const usePaymentMethodsSettingsController = () => { + const { t } = useTranslation(); + const formId = useId(); + const createMutation = usePaymentMethodCreateMutation(); + const updateMutation = usePaymentMethodUpdateMutation(); + const deleteMutation = usePaymentMethodDeleteMutation(); + const enableMutation = usePaymentMethodEnableMutation(); + const disableMutation = usePaymentMethodDisableMutation(); + const setDefaultMutation = usePaymentMethodSetDefaultMutation(); + + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [editingPaymentMethodId, setEditingPaymentMethodId] = useState(null); + const [deletingPaymentMethod, setDeletingPaymentMethod] = useState( + null + ); + + const editingPaymentMethodQuery = usePaymentMethodDetailQuery( + editingPaymentMethodId ?? undefined, + { + enabled: editingPaymentMethodId !== null, + } + ); + const editingPaymentMethod = editingPaymentMethodQuery.data ?? null; + + const form = useHookForm({ + resolverSchema: PaymentMethodFormSchema, + initialValues: buildPaymentMethodFormDefault(), + disabled: + createMutation.isPending || + updateMutation.isPending || + editingPaymentMethodQuery.isLoading, + }); + + const isSaving = + createMutation.isPending || updateMutation.isPending || editingPaymentMethodQuery.isLoading; + + const closeCreateDialog = useCallback(() => { + if (isSaving) { + return; + } + + setIsCreateOpen(false); + form.reset(buildPaymentMethodFormDefault(), { keepDirty: false }); + form.clearErrors(); + }, [form, isSaving]); + + const openCreateDialog = useCallback(() => { + setEditingPaymentMethodId(null); + form.reset(buildPaymentMethodFormDefault(), { keepDirty: false }); + form.clearErrors(); + setIsCreateOpen(true); + }, [form]); + + const openEditDialog = useCallback( + (paymentMethod: PaymentMethodListRow) => { + if (paymentMethod.isSystem) { + return; + } + + setIsCreateOpen(false); + setEditingPaymentMethodId(paymentMethod.id); + form.reset(buildPaymentMethodFormDefault(), { keepDirty: false }); + form.clearErrors(); + }, + [form] + ); + + const closeEditDialog = useCallback(() => { + if (isSaving) { + return; + } + + setEditingPaymentMethodId(null); + form.reset(buildPaymentMethodFormDefault(), { keepDirty: false }); + form.clearErrors(); + }, [form, isSaving]); + + const openDeleteDialog = useCallback((paymentMethod: PaymentMethodListRow) => { + setDeletingPaymentMethod(paymentMethod); + }, []); + + const closeDeleteDialog = useCallback(() => { + if (deleteMutation.isPending) { + return; + } + + setDeletingPaymentMethod(null); + }, [deleteMutation.isPending]); + + const notifyConflictDelete = useCallback(() => { + showErrorToast( + t("settings.paymentMethods.delete.errorTitle"), + t("settings.paymentMethods.delete.inUse") + ); + }, [t]); + + const onInvalidSubmit = useCallback( + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + showWarningToast( + t("settings.paymentMethods.messages.validationTitle"), + t("settings.paymentMethods.messages.validationDescription") + ); + }, + [form, t] + ); + + const submitPaymentMethod = form.handleSubmit( + async (values) => { + try { + if (editingPaymentMethod) { + const updated = await updateMutation.mutateAsync( + buildUpdatePaymentMethodParams(editingPaymentMethod.id, values) + ); + + showSuccessToast( + t("settings.paymentMethods.messages.updatedTitle"), + t("settings.paymentMethods.messages.updated") + ); + + setEditingPaymentMethodId(updated.id); + closeEditDialog(); + return; + } + + const id = UniqueID.generateNewID().toString(); + await createMutation.mutateAsync(buildCreatePaymentMethodParams(id, values)); + + showSuccessToast( + t("settings.paymentMethods.messages.createdTitle"), + t("settings.paymentMethods.messages.created") + ); + + closeCreateDialog(); + } catch (error: unknown) { + const normalizedError = normalizeFormSubmitError(error); + + if (isValidationErrorCollection(normalizedError)) { + applyValidationErrorCollection(form, normalizedError); + focusFirstInputFormError(form.formState.errors, form); + showWarningToast( + t("settings.paymentMethods.messages.validationTitle"), + t("settings.paymentMethods.messages.validationDescription") + ); + return; + } + + const userMessage = getUserErrorMessage(normalizedError); + showErrorToast(userMessage.title, userMessage.description); + } + }, + onInvalidSubmit + ); + + const onSubmit = useCallback( + (event: FormEvent) => { + submitPaymentMethod(event).catch((error: unknown) => { + const userMessage = getUserErrorMessage(error); + showErrorToast(userMessage.title, userMessage.description); + }); + }, + [submitPaymentMethod] + ); + + const enable = useCallback( + (paymentMethod: PaymentMethodListRow) => { + enableMutation + .mutateAsync({ paymentMethodId: paymentMethod.id }) + .then(() => { + showSuccessToast( + t("settings.paymentMethods.messages.enabledTitle"), + t("settings.paymentMethods.messages.enabled") + ); + }) + .catch((error: unknown) => { + const userMessage = getUserErrorMessage(error); + showErrorToast(userMessage.title, userMessage.description); + }); + }, + [enableMutation, t] + ); + + const disable = useCallback( + (paymentMethod: PaymentMethodListRow) => { + disableMutation + .mutateAsync({ paymentMethodId: paymentMethod.id }) + .then(() => { + showSuccessToast( + t("settings.paymentMethods.messages.disabledTitle"), + t("settings.paymentMethods.messages.disabled") + ); + }) + .catch((error: unknown) => { + const userMessage = getUserErrorMessage(error); + showErrorToast(userMessage.title, userMessage.description); + }); + }, + [disableMutation, t] + ); + + const setDefault = useCallback( + (paymentMethod: PaymentMethodListRow) => { + if (paymentMethod.isDefault || setDefaultMutation.isPending) { + return; + } + + setDefaultMutation + .mutateAsync({ paymentMethodId: paymentMethod.id }) + .then(() => { + showSuccessToast( + t("settings.paymentMethods.messages.defaultChangedTitle"), + t("settings.paymentMethods.messages.defaultChanged") + ); + }) + .catch((error: unknown) => { + const userMessage = getUserErrorMessage(error); + showErrorToast(userMessage.title, userMessage.description); + }); + }, + [setDefaultMutation, t] + ); + + const confirmDelete = useCallback(() => { + if (!deletingPaymentMethod || deleteMutation.isPending) { + return; + } + + deleteMutation + .mutateAsync({ paymentMethodId: deletingPaymentMethod.id }) + .then(() => { + showSuccessToast( + t("settings.paymentMethods.messages.deletedTitle"), + t("settings.paymentMethods.messages.deleted") + ); + closeDeleteDialog(); + }) + .catch((error: unknown) => { + if (isApiClientError(error) && error.kind === "conflict") { + notifyConflictDelete(); + return; + } + + const userMessage = getUserErrorMessage(error); + showErrorToast(userMessage.title, userMessage.description); + }); + }, [closeDeleteDialog, deleteMutation, deletingPaymentMethod, notifyConflictDelete, t]); + + useEffect(() => { + if (!editingPaymentMethod) { + return; + } + + form.reset(buildPaymentMethodFormFromEntity(editingPaymentMethod), { keepDirty: false }); + }, [editingPaymentMethod, form]); + + return { + form, + formId, + isCreateOpen, + editingPaymentMethod, + isEditingPaymentMethodLoading: editingPaymentMethodQuery.isLoading, + deletingPaymentMethod, + isSaving, + isDeleting: deleteMutation.isPending, + isChangingDefault: setDefaultMutation.isPending, + openCreateDialog, + closeCreateDialog, + openEditDialog, + closeEditDialog, + openDeleteDialog, + closeDeleteDialog, + onSubmit, + enable, + disable, + setDefault, + confirmDelete, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-page-controller.ts b/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-page-controller.ts new file mode 100644 index 00000000..9c9e168c --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/controllers/use-payment-methods-settings-page-controller.ts @@ -0,0 +1,45 @@ +import { useCallback, useMemo } from "react"; + +import { useTranslation } from "../../../i18n"; +import { usePaymentMethodsListQuery } from "../../../payment-methods/shared"; +import { + buildPaymentMethodsSettingsPageErrorViewModel, + resolvePaymentMethodsSettingsFeedbackState, +} from "../utils"; + +import { usePaymentMethodsSettingsController } from "./use-payment-methods-settings-controller"; + +export const usePaymentMethodsSettingsPageController = () => { + const { t } = useTranslation(); + const listQuery = usePaymentMethodsListQuery(); + const controller = usePaymentMethodsSettingsController(); + + const feedbackState = resolvePaymentMethodsSettingsFeedbackState({ + isLoading: listQuery.isLoading, + isError: listQuery.isError, + error: listQuery.error, + }); + + const pageError = useMemo(() => { + if (!feedbackState.hasBlockingError) { + return null; + } + + return buildPaymentMethodsSettingsPageErrorViewModel(listQuery.error, (key) => t(key)); + }, [feedbackState.hasBlockingError, listQuery.error, t]); + + const retry = useCallback(async () => { + await listQuery.refetch(); + }, [listQuery]); + + return { + list: listQuery.data?.items ?? [], + totalItems: listQuery.data?.totalItems ?? 0, + isInitialLoading: feedbackState.isInitialLoading, + pageError, + retry, + isRetrying: listQuery.isFetching && !listQuery.isLoading, + listQuery, + controller, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/entities/index.ts b/modules/catalogs/src/web/settings/payment-methods/entities/index.ts new file mode 100644 index 00000000..585a1ed1 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/entities/index.ts @@ -0,0 +1,4 @@ +export * from "./payment-method-settings-row.entity"; +export * from "./payment-method-form.entity"; +export * from "./payment-method-form.schema"; +export * from "./payment-methods-settings-page-error-view-model.entity"; diff --git a/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.entity.ts b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.entity.ts new file mode 100644 index 00000000..492d4563 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.entity.ts @@ -0,0 +1,5 @@ +export interface PaymentMethodForm { + name: string; + description: string; + isActive: boolean; +} diff --git a/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.schema.ts b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.schema.ts new file mode 100644 index 00000000..7c8ec155 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-form.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const PaymentMethodFormSchema = z.object({ + name: z.string().trim().min(1), + description: z.string().trim(), + isActive: z.boolean(), +}); + +export type PaymentMethodFormValues = z.infer; diff --git a/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-settings-row.entity.ts b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-settings-row.entity.ts new file mode 100644 index 00000000..b21af990 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/entities/payment-method-settings-row.entity.ts @@ -0,0 +1,16 @@ +export interface PaymentMethodSettingsListRow { + id: string; + name: string; + isActive: boolean; + isSystem: boolean; + isDefault: boolean; +} + +export interface PaymentMethodSettingsDetail { + id: string; + name: string; + description: string | null; + isActive: boolean; + isSystem: boolean; + isDefault: boolean; +} diff --git a/modules/catalogs/src/web/settings/payment-methods/entities/payment-methods-settings-page-error-view-model.entity.ts b/modules/catalogs/src/web/settings/payment-methods/entities/payment-methods-settings-page-error-view-model.entity.ts new file mode 100644 index 00000000..8064caef --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/entities/payment-methods-settings-page-error-view-model.entity.ts @@ -0,0 +1,9 @@ +import type { PageErrorStateVariant } from "@erp/core/components"; + +export interface PaymentMethodsSettingsPageErrorViewModel { + variant: PageErrorStateVariant; + title: string; + description: string; + reference?: string; + canRetry: boolean; +} diff --git a/modules/catalogs/src/web/settings/payment-methods/index.ts b/modules/catalogs/src/web/settings/payment-methods/index.ts new file mode 100644 index 00000000..4aedf593 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/index.ts @@ -0,0 +1 @@ +export * from "./ui"; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/delete-payment-method-dialog.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/components/delete-payment-method-dialog.tsx new file mode 100644 index 00000000..b600817b --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/delete-payment-method-dialog.tsx @@ -0,0 +1,62 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Spinner, +} from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; +import type { PaymentMethodListRow } from "../../../../payment-methods/shared"; + +interface DeletePaymentMethodDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + target: PaymentMethodListRow | null; + isSubmitting: boolean; + onConfirm: () => void; +} + +export const DeletePaymentMethodDialog = ({ + open, + onOpenChange, + target, + isSubmitting, + onConfirm, +}: DeletePaymentMethodDialogProps) => { + const { t } = useTranslation(); + + return ( + + + + {t("settings.paymentMethods.delete.title")} + + {t("settings.paymentMethods.delete.description", { + name: target?.name ?? "", + })} + + + + + + + + + + ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/index.ts b/modules/catalogs/src/web/settings/payment-methods/ui/components/index.ts new file mode 100644 index 00000000..2b5273bd --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/index.ts @@ -0,0 +1,5 @@ +export * from "./payment-method-form-dialog"; +export * from "./delete-payment-method-dialog"; +export * from "./payment-method-row-actions"; +export * from "./payment-methods-empty-state"; +export * from "./payment-methods-settings-skeleton"; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-form-dialog.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-form-dialog.tsx new file mode 100644 index 00000000..1fdc4d1e --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-form-dialog.tsx @@ -0,0 +1,120 @@ +import { FormProvider } from "react-hook-form"; +import type { FormEventHandler } from "react"; +import { RdxFormGrid, SwitchField, TextAreaField, TextField } from "@repo/rdx-ui/components"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Spinner, +} from "@repo/shadcn-ui/components"; +import { useEffect } from "react"; +import type { UseFormReturn } from "react-hook-form"; + +import { useTranslation } from "../../../../i18n"; +import type { PaymentMethodForm } from "../../entities"; + +interface PaymentMethodFormDialogProps { + open: boolean; + title: string; + description: string; + submitLabel: string; + openChange: (open: boolean) => void; + formId: string; + form: UseFormReturn; + onSubmit: FormEventHandler; + isSubmitting: boolean; + isEditMode: boolean; + disableActiveField?: boolean; +} + +export const PaymentMethodFormDialog = ({ + open, + title, + description, + submitLabel, + openChange, + formId, + form, + onSubmit, + isSubmitting, + isEditMode, + disableActiveField = false, +}: PaymentMethodFormDialogProps) => { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) { + return; + } + + form.setFocus("name"); + }, [form, open]); + + return ( + + + + {title} + {description} + + + +
+ + + + + + + +
+
+ + + + + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-row-actions.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-row-actions.tsx new file mode 100644 index 00000000..712d4cf3 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-method-row-actions.tsx @@ -0,0 +1,92 @@ +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@repo/shadcn-ui/components"; +import { Button } from "@repo/shadcn-ui/components"; +import { MoreHorizontalIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import type { PaymentMethodListRow } from "../../../../payment-methods/shared"; + +interface PaymentMethodRowActionsProps { + paymentMethod: PaymentMethodListRow; + onEdit: (paymentMethod: PaymentMethodListRow) => void; + onToggleActive: (paymentMethod: PaymentMethodListRow) => void; + onDelete: (paymentMethod: PaymentMethodListRow) => void; + activeDisabledReason?: string; + deleteDisabledReason?: string; +} + +export const PaymentMethodRowActions = ({ + paymentMethod, + onEdit, + onToggleActive, + onDelete, + activeDisabledReason, + deleteDisabledReason, +}: PaymentMethodRowActionsProps) => { + const { t } = useTranslation(); + + const editDisabled = paymentMethod.isSystem; + const toggleDisabled = paymentMethod.isSystem || paymentMethod.isDefault; + const deleteDisabled = paymentMethod.isSystem || paymentMethod.isDefault; + + return ( +
+ + + + + + + + + } + /> + onDelete(paymentMethod)} + title={deleteDisabled ? deleteDisabledReason : undefined} + variant="destructive" + > + {t("settings.paymentMethods.actions.delete")} + + } + /> + +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-empty-state.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-empty-state.tsx new file mode 100644 index 00000000..a1804f6d --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-empty-state.tsx @@ -0,0 +1,22 @@ +import { Button } from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../../../i18n"; + +export const PaymentMethodsEmptyState = ({ onCreate }: { onCreate: () => void }) => { + const { t } = useTranslation(); + + return ( +
+
+

{t("settings.paymentMethods.empty.title")}

+

+ {t("settings.paymentMethods.empty.description")} +

+
+ + +
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-settings-skeleton.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-settings-skeleton.tsx new file mode 100644 index 00000000..3595e6d3 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/components/payment-methods-settings-skeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@repo/shadcn-ui/components"; + +export const PaymentMethodsSettingsSkeleton = () => { + return ( +
+ + +
+ + + +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/index.ts b/modules/catalogs/src/web/settings/payment-methods/ui/index.ts new file mode 100644 index 00000000..566fb90c --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./pages"; +export * from "./components"; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/pages/index.ts b/modules/catalogs/src/web/settings/payment-methods/ui/pages/index.ts new file mode 100644 index 00000000..a145f9ed --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./payment-methods-settings-page"; diff --git a/modules/catalogs/src/web/settings/payment-methods/ui/pages/payment-methods-settings-page.tsx b/modules/catalogs/src/web/settings/payment-methods/ui/pages/payment-methods-settings-page.tsx new file mode 100644 index 00000000..37991ba4 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/ui/pages/payment-methods-settings-page.tsx @@ -0,0 +1,230 @@ +import { PageErrorState } from "@erp/core/components"; +import { RdxPageContainer } from "@repo/rdx-ui/components"; +import { Badge, Button, Card, CardContent, RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components"; +import { PlusIcon } from "lucide-react"; + +import { useTranslation } from "../../../../i18n"; +import { usePaymentMethodsSettingsPageController } from "../../controllers"; +import { DeletePaymentMethodDialog } from "../components/delete-payment-method-dialog"; +import { PaymentMethodFormDialog } from "../components/payment-method-form-dialog"; +import { PaymentMethodRowActions } from "../components/payment-method-row-actions"; +import { PaymentMethodsEmptyState } from "../components/payment-methods-empty-state"; +import { PaymentMethodsSettingsSkeleton } from "../components/payment-methods-settings-skeleton"; + +export const PaymentMethodsSettingsPage = () => { + const { t } = useTranslation(); + const ctrl = usePaymentMethodsSettingsPageController(); + const featureCtrl = ctrl.controller; + + if (ctrl.isInitialLoading) { + return ; + } + + if (ctrl.pageError) { + return ( + +
+ +
+
+ ); + } + + const activeDisabledReason = (isDefault: boolean, isSystem: boolean) => { + if (isSystem) { + return t("settings.paymentMethods.protections.systemDisable"); + } + + if (isDefault) { + return t("settings.paymentMethods.protections.defaultDisable"); + } + + return undefined; + }; + + const deleteDisabledReason = (isDefault: boolean, isSystem: boolean) => { + if (isSystem) { + return t("settings.paymentMethods.protections.systemDelete"); + } + + if (isDefault) { + return t("settings.paymentMethods.protections.defaultDelete"); + } + + return undefined; + }; + + return ( + +
+
+
+

+ {t("settings.paymentMethods.title")} +

+

+ {t("settings.paymentMethods.description")} +

+
+ + +
+ + {ctrl.list.length === 0 ? ( + + ) : ( + { + const paymentMethod = ctrl.list.find((item) => item.id === paymentMethodId); + + if (!paymentMethod) { + return; + } + + featureCtrl.setDefault(paymentMethod); + }} + value={ctrl.list.find((item) => item.isDefault)?.id} + > + {ctrl.list.map((paymentMethod) => ( + + +
+ + +
+
+ + + {paymentMethod.isSystem ? ( + + {t("settings.paymentMethods.status.system")} + + ) : null} + + {paymentMethod.isDefault ? ( + + {t("settings.paymentMethods.status.default")} + + ) : null} +
+ +
+ + {paymentMethod.isActive + ? t("settings.paymentMethods.status.active") + : t("settings.paymentMethods.status.inactive")} + +
+
+
+ + { + if (item.isActive) { + featureCtrl.disable(item); + return; + } + + featureCtrl.enable(item); + }} + paymentMethod={paymentMethod} + /> +
+
+ ))} +
+ )} + + { + if (!open) { + featureCtrl.closeCreateDialog(); + } + }} + submitLabel={t("settings.paymentMethods.actions.create")} + title={t("settings.paymentMethods.create.title")} + /> + + { + if (!open) { + featureCtrl.closeEditDialog(); + } + }} + submitLabel={t("settings.paymentMethods.actions.save")} + title={t("settings.paymentMethods.edit.title")} + /> + + { + if (!open) { + featureCtrl.closeDeleteDialog(); + } + }} + open={Boolean(featureCtrl.deletingPaymentMethod)} + target={featureCtrl.deletingPaymentMethod} + /> +
+
+ ); +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-form.ts b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-form.ts new file mode 100644 index 00000000..e034f578 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-form.ts @@ -0,0 +1,14 @@ +import type { PaymentMethod } from "../../../payment-methods/shared"; +import type { PaymentMethodForm } from "../entities"; + +export const buildPaymentMethodFormDefault = (): PaymentMethodForm => ({ + name: "", + description: "", + isActive: true, +}); + +export const buildPaymentMethodFormFromEntity = (paymentMethod: PaymentMethod): PaymentMethodForm => ({ + name: paymentMethod.name, + description: paymentMethod.description ?? "", + isActive: paymentMethod.isActive, +}); diff --git a/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-params.ts b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-params.ts new file mode 100644 index 00000000..508e9821 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-method-params.ts @@ -0,0 +1,30 @@ +import type { + CreatePaymentMethodParams, + UpdatePaymentMethodParams, +} from "../../../payment-methods/shared"; +import type { PaymentMethodForm } from "../entities"; + +export const buildCreatePaymentMethodParams = ( + id: string, + form: PaymentMethodForm +): CreatePaymentMethodParams => ({ + id, + data: { + id, + name: form.name.trim(), + description: form.description.trim() === "" ? null : form.description.trim(), + is_active: form.isActive, + }, +}); + +export const buildUpdatePaymentMethodParams = ( + paymentMethodId: string, + form: PaymentMethodForm +): UpdatePaymentMethodParams => ({ + paymentMethodId, + data: { + name: form.name.trim(), + description: form.description.trim() === "" ? null : form.description.trim(), + is_active: form.isActive, + }, +}); diff --git a/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-methods-settings-page-error-view-model.ts b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-methods-settings-page-error-view-model.ts new file mode 100644 index 00000000..570633bb --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/utils/build-payment-methods-settings-page-error-view-model.ts @@ -0,0 +1,43 @@ +import { isApiClientError } from "@erp/core/client"; + +import type { PaymentMethodsSettingsPageErrorViewModel } from "../entities"; + +export const buildPaymentMethodsSettingsPageErrorViewModel = ( + error: unknown, + translate: (key: string) => string +): PaymentMethodsSettingsPageErrorViewModel => { + if (!isApiClientError(error)) { + return { + variant: "server", + title: translate("settings.paymentMethods.pageError.unknown.title"), + description: translate("settings.paymentMethods.pageError.unknown.description"), + canRetry: true, + }; + } + + if (error.kind === "forbidden") { + return { + variant: "forbidden", + title: translate("settings.paymentMethods.pageError.forbidden.title"), + description: translate("settings.paymentMethods.pageError.forbidden.description"), + reference: error.correlationId, + canRetry: false, + }; + } + + const variant = + error.kind === "network" || + error.kind === "timeout" || + error.kind === "unavailable" || + error.kind === "notFound" + ? error.kind + : "server"; + + return { + variant, + title: translate(`settings.paymentMethods.pageError.${variant}.title`), + description: translate(`settings.paymentMethods.pageError.${variant}.description`), + reference: error.correlationId, + canRetry: true, + }; +}; diff --git a/modules/catalogs/src/web/settings/payment-methods/utils/index.ts b/modules/catalogs/src/web/settings/payment-methods/utils/index.ts new file mode 100644 index 00000000..93cdf2ef --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./build-payment-method-form"; +export * from "./build-payment-method-params"; +export * from "./build-payment-methods-settings-page-error-view-model"; +export * from "./resolve-payment-methods-settings-feedback-state"; diff --git a/modules/catalogs/src/web/settings/payment-methods/utils/resolve-payment-methods-settings-feedback-state.ts b/modules/catalogs/src/web/settings/payment-methods/utils/resolve-payment-methods-settings-feedback-state.ts new file mode 100644 index 00000000..a1dfdf33 --- /dev/null +++ b/modules/catalogs/src/web/settings/payment-methods/utils/resolve-payment-methods-settings-feedback-state.ts @@ -0,0 +1,14 @@ +import { isApiClientError } from "@erp/core/client"; + +export const resolvePaymentMethodsSettingsFeedbackState = (params: { + isLoading: boolean; + isError: boolean; + error: unknown; +}) => { + const isCancelled = isApiClientError(params.error) && params.error.kind === "cancelled"; + + return { + isInitialLoading: params.isLoading, + hasBlockingError: params.isError && !isCancelled, + }; +};