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 (
+
+ );
+};
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,
+ };
+};