feat(payment-methods): add payment method management functionality

- Implemented `usePaymentMethodUpdateMutation` for updating payment methods with validation.
- Created settings controllers and hooks for managing payment methods.
- Added UI components for creating, editing, deleting, and displaying payment methods.
- Introduced error handling and feedback mechanisms for payment method operations.
- Developed form schemas and utility functions for handling payment method data.
- Established a new settings page for managing payment methods with appropriate loading and error states.
This commit is contained in:
David Arranz 2026-08-13 01:04:56 +02:00
parent 45200284ed
commit 5dfbdd2a1c
104 changed files with 3637 additions and 44 deletions

View File

@ -38,6 +38,7 @@
"title": "Settings", "title": "Settings",
"description": "Configure the behavior of the active company.", "description": "Configure the behavior of the active company.",
"sections": { "sections": {
"general": "General",
"company": "Company", "company": "Company",
"billing": "Billing" "billing": "Billing"
}, },

View File

@ -38,6 +38,7 @@
"title": "Configuración", "title": "Configuración",
"description": "Configura el comportamiento de la empresa activa.", "description": "Configura el comportamiento de la empresa activa.",
"sections": { "sections": {
"general": "General",
"company": "Empresa", "company": "Empresa",
"billing": "Facturación" "billing": "Facturación"
}, },

View File

@ -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", () => { test("falla si un módulo declara una sección inexistente", () => {
assert.throws( assert.throws(
() => () =>

View File

@ -47,6 +47,9 @@ const renderSettingsRouter = async ({
companyDetails: { companyDetails: {
title: "Datos de empresa", title: "Datos de empresa",
}, },
paymentMethods: {
title: "Métodos de pago",
},
}, },
}); });
await i18n.changeLanguage("es"); await i18n.changeLanguage("es");
@ -117,6 +120,37 @@ test("resuelve un deep link modular bajo /settings", async () => {
assert.match(html, /Contenido real de configuracion/); 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: <div>Contenido real de metodos de pago</div>,
},
],
}),
],
});
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 () => { test("muestra 404 para una ruta inválida bajo /settings", async () => {
const html = await renderSettingsRouter({ const html = await renderSettingsRouter({
initialEntry: "/settings/unknown/path", initialEntry: "/settings/unknown/path",

View File

@ -4,6 +4,10 @@ export interface SettingsSectionCatalogItem {
} }
export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [ export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [
{
id: "general",
labelKey: "web.settings.sections.general",
},
{ {
id: "company", id: "company",
labelKey: "web.settings.sections.company", labelKey: "web.settings.sections.company",

View File

@ -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

View File

@ -0,0 +1,3 @@
# Catalogs SQL
Scripts manuales e idempotentes para evolución del módulo `catalogs`.

View File

@ -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;

View File

@ -1,10 +1,15 @@
import type { IPaymentMethodRepository } from "../repositories"; import type { IPaymentMethodRepository } from "../repositories";
import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services"; import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services";
import type { IPaymentMethodUsageChecker } from "../services";
export const buildPaymentMethodDeleter = (params: { export const buildPaymentMethodDeleter = (params: {
repository: IPaymentMethodRepository; repository: IPaymentMethodRepository;
usageChecker: IPaymentMethodUsageChecker;
}): IPaymentMethodDeleter => { }): IPaymentMethodDeleter => {
const { repository } = params; const { repository, usageChecker } = params;
return new PaymentMethodDeleter(repository); return new PaymentMethodDeleter({
repository,
usageChecker,
});
}; };

View File

@ -8,7 +8,7 @@ import {
extractOrPushError, extractOrPushError,
maybeFromNullableResult, maybeFromNullableResult,
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { CreatePaymentMethodRequestDTO } from "../../../../common"; import type { CreatePaymentMethodRequestDTO } from "../../../../common";
import type { IPaymentMethodCreateProps } from "../../../domain"; import type { IPaymentMethodCreateProps } from "../../../domain";
@ -48,6 +48,8 @@ export class CreatePaymentMethodInputMapper implements ICreatePaymentMethodInput
description: description!, description: description!,
isActive: isActive!, isActive: isActive!,
isSystem: false, isSystem: false,
isDefault: false,
systemKey: Maybe.none(),
}; };
return Result.ok({ return Result.ok({

View File

@ -8,4 +8,5 @@ export type PaymentMethodDetail = {
description: Maybe<TextValue>; description: Maybe<TextValue>;
is_active: boolean; is_active: boolean;
is_system: boolean; is_system: boolean;
is_default: boolean;
}; };

View File

@ -6,4 +6,5 @@ export type PaymentMethodSummary = {
name: Name; name: Name;
isActive: boolean; isActive: boolean;
isSystem: boolean; isSystem: boolean;
isDefault: boolean;
}; };

View File

@ -12,7 +12,7 @@ export class PaymentMethodPublicModelMapper {
name: paymentMethod.name.toPrimitive(), name: paymentMethod.name.toPrimitive(),
description: paymentMethod.description.map((value) => value.toPrimitive()), description: paymentMethod.description.map((value) => value.toPrimitive()),
isActive: paymentMethod.isActive, isActive: paymentMethod.isActive,
isDefault: paymentMethod.isDefault,
}; };
} }
} }

View File

@ -9,6 +9,5 @@ export interface PaymentMethodPublicModel {
description: Maybe<string>; description: Maybe<string>;
isActive: boolean; isActive: boolean;
isDefault: boolean;
} }

View File

@ -2,7 +2,7 @@ import type { Criteria } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils"; 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"; import type { PaymentMethodSummary } from "../models";
export interface IPaymentMethodRepository { export interface IPaymentMethodRepository {
@ -28,6 +28,33 @@ export interface IPaymentMethodRepository {
criteria: Criteria, criteria: Criteria,
transaction?: unknown transaction?: unknown
): Promise<Result<Collection<PaymentMethodSummary>, Error>>; ): Promise<Result<Collection<PaymentMethodSummary>, Error>>;
findDefaultInCompany(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<PaymentMethod>, Error>>;
findBySystemKeyInCompany(
companyId: UniqueID,
systemKey: PaymentMethodSystemKey,
transaction?: unknown,
options?: { includeDeleted?: boolean }
): Promise<Result<Maybe<PaymentMethod>, Error>>;
restoreByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction?: unknown
): Promise<Result<boolean, Error>>;
syncSystemPaymentMethodState(params: {
companyId: UniqueID;
id: UniqueID;
name: string;
isDefault: boolean;
systemKey: PaymentMethodSystemKey;
transaction?: unknown;
}): Promise<Result<void, Error>>;
lockByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<void, Error>>;
deleteByIdInCompany( deleteByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,

View File

@ -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<string, PaymentMethod>();
private readonly deleted = new Set<string>();
async create(paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
this.items.set(paymentMethod.id.toString(), paymentMethod);
return Result.ok();
}
async update(paymentMethod: PaymentMethod): Promise<Result<void, Error>> {
this.items.set(paymentMethod.id.toString(), paymentMethod);
return Result.ok();
}
async existsByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
const paymentMethod = this.items.get(id.toString());
return Result.ok(
Boolean(paymentMethod && paymentMethod.companyId.toString() === companyId.toString())
);
}
async getByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<PaymentMethod, Error>> {
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<Result<Maybe<PaymentMethod>, 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<Result<Collection<PaymentMethodSummary>, Error>> {
return Result.ok(Collection.create<PaymentMethodSummary>([]));
}
async findDefaultInCompany(companyId: UniqueID): Promise<Result<Maybe<PaymentMethod>, 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<Result<Maybe<PaymentMethod>, 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<Result<boolean, Error>> {
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<Result<void, Error>> {
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<Result<void, Error>> {
return Result.ok();
}
async deleteByIdInCompany(companyId: UniqueID, id: UniqueID): Promise<Result<boolean, Error>> {
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);
});
});

View File

@ -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<Result<PaymentMethod[], Error>>;
}
export class BootstrapSystemPaymentMethodsService implements IBootstrapSystemPaymentMethodsService {
public constructor(
private readonly deps: {
repository: IPaymentMethodRepository;
setDefaultService: ISetDefaultPaymentMethodService;
}
) {}
public async bootstrap(params: {
companyId: UniqueID;
transaction?: unknown;
}): Promise<Result<PaymentMethod[], Error>> {
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<Result<PaymentMethod, Error>> {
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);
}
}

View File

@ -1,8 +1,11 @@
export * from "./payment-method-creator"; export * from "./payment-method-creator";
export * from "./bootstrap-system-payment-methods.service";
export * from "./payment-method-deleter"; export * from "./payment-method-deleter";
export * from "./payment-method-disabler"; export * from "./payment-method-disabler";
export * from "./payment-method-enabler"; export * from "./payment-method-enabler";
export * from "./payment-method-finder"; export * from "./payment-method-finder";
export * from "./payment-method-public-services"; export * from "./payment-method-public-services";
export * from "./payment-method-usage-checker";
export * from "./payment-method-status-changer"; export * from "./payment-method-status-changer";
export * from "./payment-method-updater"; export * from "./payment-method-updater";
export * from "./set-default-payment-method.service";

View File

@ -1,8 +1,9 @@
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { PaymentMethod } from "../../../domain"; import type { PaymentMethod } from "../../../domain";
import { PaymentMethodCannotBeDeletedError } from "../../../domain"; import { PaymentMethodInUseError } from "../../../domain";
import type { IPaymentMethodRepository } from "../repositories"; import type { IPaymentMethodRepository } from "../repositories";
import type { IPaymentMethodUsageChecker } from "./payment-method-usage-checker";
export interface IPaymentMethodDeleter { export interface IPaymentMethodDeleter {
delete(params: { delete(params: {
@ -12,7 +13,12 @@ export interface IPaymentMethodDeleter {
} }
export class PaymentMethodDeleter implements 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: { public async delete(params: {
paymentMethod: PaymentMethod; paymentMethod: PaymentMethod;
@ -20,13 +26,31 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter {
}): Promise<Result<PaymentMethod, Error>> { }): Promise<Result<PaymentMethod, Error>> {
const { paymentMethod, transaction } = params; 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( 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.companyId,
paymentMethod.id, paymentMethod.id,
transaction transaction

View File

@ -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<Result<PaymentMethodUsageDetail[], Error>>;
}
export class PaymentMethodUsageChecker implements IPaymentMethodUsageChecker {
public constructor(
private readonly deps: {
customersUsageFinder: {
existsByPaymentMethodId(params: {
companyId: UniqueID;
paymentMethodId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
proformasUsageFinder: {
existsByPaymentMethodId(params: {
companyId: UniqueID;
paymentMethodId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
supplierInvoicesUsageFinder: {
existsByPaymentMethodId(params: {
companyId: UniqueID;
paymentMethodId: UniqueID;
transaction?: unknown;
}): Promise<Result<boolean, Error>>;
};
}
) {}
public async findUsage(params: {
companyId: UniqueID;
paymentMethodId: UniqueID;
transaction?: unknown;
}): Promise<Result<PaymentMethodUsageDetail[], Error>> {
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);
}
}

View File

@ -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<Result<PaymentMethod, Error>>;
}
export class SetDefaultPaymentMethodService implements ISetDefaultPaymentMethodService {
public constructor(private readonly repository: IPaymentMethodRepository) {}
public async setDefault(params: {
companyId: UniqueID;
paymentMethodId: UniqueID;
transaction?: unknown;
}): Promise<Result<PaymentMethod, Error>> {
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);
}
}

View File

@ -20,6 +20,7 @@ export class PaymentMethodFullSnapshotBuilder implements IPaymentMethodFullSnaps
is_active: paymentMethod.isActive, is_active: paymentMethod.isActive,
is_system: paymentMethod.isSystem, is_system: paymentMethod.isSystem,
is_default: paymentMethod.isDefault,
}; };
} }
} }

View File

@ -16,6 +16,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar
is_system: paymentMethod.isSystem, is_system: paymentMethod.isSystem,
is_active: paymentMethod.isActive, is_active: paymentMethod.isActive,
is_default: paymentMethod.isDefault,
}; };
} }
} }

View File

@ -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<Result<BootstrapSystemPaymentMethodsResponseDTO, Error>> {
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);
}
});
}
}

View File

@ -1,7 +1,9 @@
export * from "./bootstrap-system-payment-methods.use-case";
export * from "./create-payment-method.use-case"; export * from "./create-payment-method.use-case";
export * from "./delete-payment-method-by-id.use-case"; export * from "./delete-payment-method-by-id.use-case";
export * from "./disable-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 "./enable-payment-method-by-id.use-case";
export * from "./get-payment-method-by-id.use-case"; export * from "./get-payment-method-by-id.use-case";
export * from "./list-payment-methods.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"; export * from "./update-payment-method-by-id.use-case";

View File

@ -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);
}
});
}
}

View File

@ -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);
});
});

View File

@ -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 { export class PaymentMethodCannotBeDisabledError extends DomainError {
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DISABLED" as const; 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 { export class PaymentMethodCannotBeEnabledError extends DomainError {
public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const; public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const;
} }

View File

@ -1,3 +1,4 @@
export * from "./errors"; export * from "./errors";
export * from "./payment-method.aggregate"; export * from "./payment-method.aggregate";
export * from "./payment-method-name"; export * from "./payment-method-name";
export * from "./payment-method-system-key";

View File

@ -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);
};

View File

@ -1,7 +1,13 @@
import { AggregateRoot, type Name, type TextValue, type UniqueID } from "@repo/rdx-ddd"; import { AggregateRoot, type Name, type TextValue, type UniqueID } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; 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 { export interface IPaymentMethodCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -9,6 +15,8 @@ export interface IPaymentMethodCreateProps {
description: Maybe<TextValue>; description: Maybe<TextValue>;
isActive: boolean; isActive: boolean;
isSystem: boolean; isSystem: boolean;
isDefault: boolean;
systemKey: Maybe<PaymentMethodSystemKey>;
} }
export type PaymentMethodPatchProps = Partial<{ export type PaymentMethodPatchProps = Partial<{
@ -57,6 +65,20 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
return Result.fail(new Error("Payment method name is required")); 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(); return Result.ok();
} }
@ -88,6 +110,14 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
return this.props.isSystem; return this.props.isSystem;
} }
public get isDefault(): boolean {
return this.props.isDefault;
}
public get systemKey(): Maybe<PaymentMethodSystemKey> {
return this.props.systemKey;
}
public update(patchProps: PaymentMethodPatchProps): Result<boolean, Error> { public update(patchProps: PaymentMethodPatchProps): Result<boolean, Error> {
if (this.isSystem) { if (this.isSystem) {
return Result.fail( return Result.fail(
@ -133,6 +163,14 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
); );
} }
if (this.isDefault) {
return Result.fail(
new PaymentMethodCannotBeDisabledError(
"Default payment methods cannot be disabled until another default is selected."
)
);
}
if (!this.isActive) { if (!this.isActive) {
return Result.ok(false); return Result.ok(false);
} }
@ -156,6 +194,50 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
return Result.ok(true); return Result.ok(true);
} }
public markAsDefault(): Result<boolean, Error> {
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<boolean, Error> {
if (!this.isDefault) {
return Result.ok(false);
}
this.props.isDefault = false;
return Result.ok(true);
}
public ensureCanBeDeleted(): Result<void, Error> {
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() { public toJSON() {
return { return {
id: this.id.toPrimitive(), id: this.id.toPrimitive(),
@ -167,6 +249,7 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
), ),
is_active: this.isActive, is_active: this.isActive,
is_system: this.isSystem, is_system: this.isSystem,
is_default: this.isDefault,
}; };
} }

View File

@ -3,8 +3,12 @@ import type { Sequelize } from "sequelize";
import { import {
type IPaymentMethodPublicFinder, type IPaymentMethodPublicFinder,
BootstrapSystemPaymentMethodsService,
BootstrapSystemPaymentMethodsUseCase,
PaymentMethodPublicFinder, PaymentMethodPublicFinder,
PaymentMethodPublicModelMapper, PaymentMethodPublicModelMapper,
SetDefaultPaymentMethodByIdUseCase,
SetDefaultPaymentMethodService,
buildPaymentMethodCreator, buildPaymentMethodCreator,
buildPaymentMethodDeleter, buildPaymentMethodDeleter,
buildPaymentMethodFinder, buildPaymentMethodFinder,
@ -26,6 +30,7 @@ import {
import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di"; import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di";
import { buildPaymentMethodRepository } from "./payment-method-repositories.di"; import { buildPaymentMethodRepository } from "./payment-method-repositories.di";
import { buildPaymentMethodReferenceUsageChecker } from "../payment-method-reference-usage-checker";
export type PaymentMethodsInternalDeps = { export type PaymentMethodsInternalDeps = {
repository: IPaymentMethodRepository; repository: IPaymentMethodRepository;
@ -37,6 +42,8 @@ export type PaymentMethodsInternalDeps = {
deletePaymentMethodById: () => DeletePaymentMethodByIdUseCase; deletePaymentMethodById: () => DeletePaymentMethodByIdUseCase;
disablePaymentMethodById: () => DisablePaymentMethodByIdUseCase; disablePaymentMethodById: () => DisablePaymentMethodByIdUseCase;
enablePaymentMethodById: () => EnablePaymentMethodByIdUseCase; enablePaymentMethodById: () => EnablePaymentMethodByIdUseCase;
setDefaultPaymentMethodById: () => SetDefaultPaymentMethodByIdUseCase;
bootstrapSystemPaymentMethods: () => BootstrapSystemPaymentMethodsUseCase;
}; };
}; };
@ -50,14 +57,20 @@ export const buildPaymentMethodsDependencies = (
const persistenceMappers = buildPaymentMethodPersistenceMappers(); const persistenceMappers = buildPaymentMethodPersistenceMappers();
const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers }); const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers });
const usageChecker = buildPaymentMethodReferenceUsageChecker(database as Sequelize);
// Application helpers // Application helpers
const inputMappers = buildPaymentMethodInputMappers(); const inputMappers = buildPaymentMethodInputMappers();
const finder = buildPaymentMethodFinder({ repository }); const finder = buildPaymentMethodFinder({ repository });
const creator = buildPaymentMethodCreator({ repository }); const creator = buildPaymentMethodCreator({ repository });
const updater = buildPaymentMethodUpdater({ repository }); const updater = buildPaymentMethodUpdater({ repository });
const deleter = buildPaymentMethodDeleter({ repository }); const deleter = buildPaymentMethodDeleter({ repository, usageChecker });
const statusChanger = buildPaymentMethodStatusChanger({ repository }); const statusChanger = buildPaymentMethodStatusChanger({ repository });
const setDefaultService = new SetDefaultPaymentMethodService(repository);
const bootstrapService = new BootstrapSystemPaymentMethodsService({
repository,
setDefaultService,
});
const snapshotBuilders = buildPaymentMethodSnapshotBuilders(); const snapshotBuilders = buildPaymentMethodSnapshotBuilders();
@ -110,6 +123,20 @@ export const buildPaymentMethodsDependencies = (
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,
}), }),
setDefaultPaymentMethodById: () =>
new SetDefaultPaymentMethodByIdUseCase({
setDefaultService,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
bootstrapSystemPaymentMethods: () =>
new BootstrapSystemPaymentMethodsUseCase({
bootstrapService,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
}, },
}; };
}; };

View File

@ -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)
);
}
}

View File

@ -4,4 +4,6 @@ export * from "./disable-payment-method-by-id.controller";
export * from "./enable-payment-method-by-id.controller"; export * from "./enable-payment-method-by-id.controller";
export * from "./get-payment-method-by-id.controller"; export * from "./get-payment-method-by-id.controller";
export * from "./list-payment-methods.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"; export * from "./update-payment-method-by-id.controller";

View File

@ -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)
);
}
}

View File

@ -9,17 +9,21 @@ import {
import { import {
type InvalidPaymentMethodIdError, type InvalidPaymentMethodIdError,
type InvalidPaymentMethodNameError, type InvalidPaymentMethodNameError,
type PaymentMethodCannotBeDefaultError,
type PaymentMethodCannotBeDeletedError, type PaymentMethodCannotBeDeletedError,
type PaymentMethodCannotBeDisabledError, type PaymentMethodCannotBeDisabledError,
type PaymentMethodCannotBeEnabledError, type PaymentMethodCannotBeEnabledError,
type PaymentMethodCannotBeUpdatedError, type PaymentMethodCannotBeUpdatedError,
type PaymentMethodInUseError,
type PaymentMethodNotFoundError, type PaymentMethodNotFoundError,
isInvalidPaymentMethodIdError, isInvalidPaymentMethodIdError,
isInvalidPaymentMethodNameError, isInvalidPaymentMethodNameError,
isPaymentMethodCannotBeDefaultError,
isPaymentMethodCannotBeDeletedError, isPaymentMethodCannotBeDeletedError,
isPaymentMethodCannotBeDisabledError, isPaymentMethodCannotBeDisabledError,
isPaymentMethodCannotBeEnabledError, isPaymentMethodCannotBeEnabledError,
isPaymentMethodCannotBeUpdatedError, isPaymentMethodCannotBeUpdatedError,
isPaymentMethodInUseError,
isPaymentMethodNotFoundError, isPaymentMethodNotFoundError,
} from "../../../domain/payment-methods"; } 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 = { const paymentMethodCannotBeDisabledRule: ErrorToApiRule = {
priority: 120, priority: 120,
matches: isPaymentMethodCannotBeDisabledError, 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() export const paymentMethodsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invalidPaymentMethodIdRule) .register(invalidPaymentMethodIdRule)
.register(invalidPaymentMethodNameRule) .register(invalidPaymentMethodNameRule)
.register(paymentMethodNotFoundRule) .register(paymentMethodNotFoundRule)
.register(paymentMethodCannotBeDefaultRule)
.register(paymentMethodCannotBeDeletedRule) .register(paymentMethodCannotBeDeletedRule)
.register(paymentMethodCannotBeDisabledRule) .register(paymentMethodCannotBeDisabledRule)
.register(paymentMethodCannotBeEnabledRule) .register(paymentMethodCannotBeEnabledRule)
.register(paymentMethodCannotBeUpdatedRule); .register(paymentMethodCannotBeUpdatedRule)
.register(paymentMethodInUseRule);

View File

@ -3,22 +3,26 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express"; import { type NextFunction, type Request, type Response, Router } from "express";
import { import {
BootstrapSystemPaymentMethodsRequestSchema,
CreatePaymentMethodRequestSchema, CreatePaymentMethodRequestSchema,
DeletePaymentMethodByIdRequestSchema, DeletePaymentMethodByIdRequestSchema,
GetPaymentMethodByIdRequestSchema, GetPaymentMethodByIdRequestSchema,
ListPaymentMethodsRequestSchema, ListPaymentMethodsRequestSchema,
SetDefaultPaymentMethodByIdRequestSchema,
UpdatePaymentMethodByIdParamsRequestSchema, UpdatePaymentMethodByIdParamsRequestSchema,
UpdatePaymentMethodByIdRequestSchema, UpdatePaymentMethodByIdRequestSchema,
} from "../../../../common"; } from "../../../../common";
import type { CatalogsInternalDeps } from "../../di/catalogs.di"; import type { CatalogsInternalDeps } from "../../di/catalogs.di";
import { import {
BootstrapSystemPaymentMethodsController,
CreatePaymentMethodController, CreatePaymentMethodController,
DeletePaymentMethodByIdController, DeletePaymentMethodByIdController,
DisablePaymentMethodByIdController, DisablePaymentMethodByIdController,
EnablePaymentMethodByIdController, EnablePaymentMethodByIdController,
GetPaymentMethodByIdController, GetPaymentMethodByIdController,
ListPaymentMethodsController, ListPaymentMethodsController,
SetDefaultPaymentMethodByIdController,
UpdatePaymentMethodByIdController, UpdatePaymentMethodByIdController,
} from "./controllers"; } 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( router.post(
"/", "/",
validateRequest(CreatePaymentMethodRequestSchema, "body"), 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( router.patch(
"/:payment_method_id/disable", "/:payment_method_id/disable",
validateRequest(GetPaymentMethodByIdRequestSchema, "params"), validateRequest(GetPaymentMethodByIdRequestSchema, "params"),

View File

@ -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<string, string>;
transaction?: Transaction;
}) => Promise<number>;
};
type SequelizeModelsRegistry = Record<string, CountableModel | undefined>;
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",
}),
},
});
};

View File

@ -11,7 +11,7 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { PaymentMethod } from "../../../../../domain"; import { PaymentMethod, isPaymentMethodSystemKey } from "../../../../../domain";
import type { PaymentMethodCreationAttributes, PaymentMethodModel } from "../models"; import type { PaymentMethodCreationAttributes, PaymentMethodModel } from "../models";
export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper< export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
@ -44,6 +44,15 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
"description", "description",
errors 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 // Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
@ -59,6 +68,8 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
description: description!, description: description!,
isActive: source.is_active, isActive: source.is_active,
isSystem: source.is_system, isSystem: source.is_system,
isDefault: source.is_default,
systemKey: systemKey!,
}, },
idResult.data idResult.data
); );
@ -80,6 +91,11 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
description: maybeToNullable(source.description, (description) => description.toPrimitive()), description: maybeToNullable(source.description, (description) => description.toPrimitive()),
is_active: source.isActive, is_active: source.isActive,
is_system: source.isSystem, is_system: source.isSystem,
is_default: source.isDefault,
system_key: source.systemKey.match(
(systemKey) => systemKey,
() => null
),
}); });
} }
} }

View File

@ -27,6 +27,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper<
const name = extractOrPushError(Name.create(raw.name), "name", errors); const name = extractOrPushError(Name.create(raw.name), "name", errors);
const isActive = raw.is_active; const isActive = raw.is_active;
const isSystem = raw.is_system; const isSystem = raw.is_system;
const isDefault = raw.is_default;
// Si hubo errores de mapeo, devolvemos colección de validación // Si hubo errores de mapeo, devolvemos colección de validación
if (errors.length > 0) { if (errors.length > 0) {
@ -41,6 +42,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper<
name: name!, name: name!,
isActive: isActive, isActive: isActive,
isSystem: isSystem, isSystem: isSystem,
isDefault: isDefault,
}); });
} }
} }

View File

@ -19,6 +19,8 @@ export class PaymentMethodModel extends Model<
declare description: CreationOptional<string | null>; declare description: CreationOptional<string | null>;
declare is_active: boolean; declare is_active: boolean;
declare is_system: boolean; declare is_system: boolean;
declare is_default: boolean;
declare system_key: CreationOptional<string | null>;
static associate(_database: Sequelize) {} static associate(_database: Sequelize) {}
@ -58,6 +60,16 @@ export default (database: Sequelize) => {
allowNull: false, allowNull: false,
defaultValue: false, defaultValue: false,
}, },
is_default: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
system_key: {
type: DataTypes.STRING(40),
allowNull: true,
defaultValue: null,
},
}, },
{ {
sequelize: database, sequelize: database,
@ -79,6 +91,13 @@ export default (database: Sequelize) => {
defaultScope: {}, defaultScope: {},
scopes: {}, scopes: {},
indexes: [
{
name: "uq_payment_methods_company_system_key",
fields: ["company_id", "system_key"],
unique: true,
},
],
} }
); );

View File

@ -11,7 +11,7 @@ import type { Sequelize, Transaction } from "sequelize";
import type { IPaymentMethodRepository } from "../../../../../application"; import type { IPaymentMethodRepository } from "../../../../../application";
import type { PaymentMethodSummary } from "../../../../../application/payment-methods/models"; 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 { SequelizePaymentMethodSummaryMapper } from "../mappers";
import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper"; import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper";
import { PaymentMethodModel } from "../models"; import { PaymentMethodModel } from "../models";
@ -174,6 +174,138 @@ export class SequelizePaymentMethodRepository
} }
} }
public async findDefaultInCompany(
companyId: UniqueID,
transaction?: Transaction
): Promise<Result<Maybe<PaymentMethod>, Error>> {
try {
const row = await PaymentMethodModel.findOne({
where: {
company_id: companyId.toString(),
is_default: true,
},
transaction,
});
if (!row) {
return Result.ok(Maybe.none<PaymentMethod>());
}
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<Result<Maybe<PaymentMethod>, 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<PaymentMethod>());
}
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<Result<boolean, Error>> {
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<Result<void, Error>> {
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<Result<void, Error>> {
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.). * Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
* *

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const BootstrapSystemPaymentMethodsRequestSchema = z.object({});
export type BootstrapSystemPaymentMethodsRequestDTO = z.infer<
typeof BootstrapSystemPaymentMethodsRequestSchema
>;

View File

@ -1,5 +1,7 @@
export * from "./create-payment-method.request.dto"; 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 "./delete-payment-method-by-id.request.dto";
export * from "./get-payment-method-by-id.request.dto"; export * from "./get-payment-method-by-id.request.dto";
export * from "./list-payment-methods.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"; export * from "./update-payment-method-by-id.request.dto";

View File

@ -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
>;

View File

@ -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
>;

View File

@ -1,5 +1,7 @@
export * from "./create-payment-method.response.dto"; 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 "./disable-payment-method-by-id.response.dto";
export * from "./get-payment-method-by-id.response.dto"; export * from "./get-payment-method-by-id.response.dto";
export * from "./list-payment-methods.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"; export * from "./update-payment-method-by-id.response.dto";

View File

@ -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
>;

View File

@ -8,6 +8,7 @@ export const PaymentMethodDetailSchema = z.object({
description: z.string().nullable(), description: z.string().nullable(),
is_active: z.boolean(), is_active: z.boolean(),
is_system: z.boolean(), is_system: z.boolean(),
is_default: z.boolean(),
}); });
export type PaymentMethodDetailDTO = z.infer<typeof PaymentMethodDetailSchema>; export type PaymentMethodDetailDTO = z.infer<typeof PaymentMethodDetailSchema>;

View File

@ -7,6 +7,7 @@ export const PaymentMethodSummarySchema = z.object({
name: z.string(), name: z.string(),
is_active: z.boolean(), is_active: z.boolean(),
is_system: z.boolean(), is_system: z.boolean(),
is_default: z.boolean(),
}); });
export type PaymentMethodSummaryDTO = z.infer<typeof PaymentMethodSummarySchema>; export type PaymentMethodSummaryDTO = z.infer<typeof PaymentMethodSummarySchema>;

View File

@ -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."
}
}
}
}
}

View File

@ -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."
}
}
}
}
}

View File

@ -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: <PaymentMethodsSettingsPage />,
},
];
};

View File

@ -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: <explanation>
useEffect(() => {
ensureModuleTranslations();
}, [i18n]);
// Hook con namespace
return useI18NextTranslation(MODULE_NAME, {
i18n,
});
};

View File

@ -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);
});

View File

@ -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"; export const MODULE_NAME = "Catalogs";
const MODULE_VERSION = "1.0.0"; const MODULE_VERSION = "1.0.0";
ensureModuleTranslations();
export const CatalogsModuleManifest: IModuleClient = { export const CatalogsModuleManifest: IModuleClient = {
name: MODULE_NAME, name: MODULE_NAME,
version: MODULE_VERSION, version: MODULE_VERSION,
dependencies: ["auth", "Core"], dependencies: ["auth", "Core"],
protected: true, protected: true,
layout: "app-sidebar", layout: "app-sidebar",
settings: {
routes: (params: ModuleClientParams) => { entries: [
return []; {
id: "payment-methods",
sectionId: "general",
labelKey: "settings.paymentMethods.title",
path: "/settings/general/payment-methods",
},
],
}, },
routes: (params) => CatalogsRoutes(params),
}; };
export default CatalogsModuleManifest; export default CatalogsModuleManifest;

View File

@ -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,
};
},
};

View File

@ -1 +1,2 @@
export * from './list-payment-methods.adapter'; export * from "./list-payment-methods.adapter";
export * from "./get-payment-method-by-id.adapter";

View File

@ -15,13 +15,13 @@ import type { PaymentMethodList, PaymentMethodListRow } from "../entities";
*/ */
export const ListPaymentMethodsAdapter = { export const ListPaymentMethodsAdapter = {
fromDto(dto: ListPaymentMethodsResult, context?: unknown): PaymentMethodList { fromDto(dto: ListPaymentMethodsResult): PaymentMethodList {
return { return {
page: dto.page, page: dto.page,
perPage: dto.per_page, perPage: dto.per_page,
totalPages: dto.total_pages, totalPages: dto.total_pages,
totalItems: dto.total_items, 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]; type ListPaymentMethodsItemOutput = ListPaymentMethodsResponseDTO["items"][number];
const PaymentMehodListRowAdapter = { const PaymentMehodListRowAdapter = {
fromDto(dto: ListPaymentMethodsItemOutput, context?: unknown): PaymentMethodListRow { fromDto(dto: ListPaymentMethodsItemOutput): PaymentMethodListRow {
return { return {
id: dto.id, id: dto.id,
companyId: dto.company_id, companyId: dto.company_id,
@ -48,6 +48,7 @@ const PaymentMehodListRowAdapter = {
isSystem: dto.is_system, isSystem: dto.is_system,
isActive: dto.is_active, isActive: dto.is_active,
isDefault: dto.is_default,
}; };
}, },
}; };

View File

@ -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<CreatePaymentMethodResponseDTO> {
return dataSource.createOne<CreatePaymentMethodRequestDTO, CreatePaymentMethodResponseDTO>(
"catalogs/payment-methods",
params.data
);
}

View File

@ -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);
}

View File

@ -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<DisablePaymentMethodByIdResponseDTO> {
return dataSource.custom<undefined, DisablePaymentMethodByIdResponseDTO>({
method: "patch",
path: `catalogs/payment-methods/${params.paymentMethodId}/disable`,
data: undefined,
});
}

View File

@ -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<UpdatePaymentMethodByIdResponseDTO> {
return dataSource.custom<undefined, UpdatePaymentMethodByIdResponseDTO>({
method: "patch",
path: `catalogs/payment-methods/${params.paymentMethodId}/enable`,
data: undefined,
});
}

View File

@ -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<GetPaymentMethodByIdResponseDTO> {
return dataSource.getOne<GetPaymentMethodByIdResponseDTO>(
"catalogs/payment-methods",
params.paymentMethodId,
{
signal: params.signal,
}
);
}

View File

@ -1 +1,8 @@
export * from "./list-payment-methods-by-criteria.api"; 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";

View File

@ -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<SetDefaultPaymentMethodByIdResponseDTO> {
return dataSource.custom<undefined, SetDefaultPaymentMethodByIdResponseDTO>({
method: "patch",
path: `catalogs/payment-methods/${params.paymentMethodId}/default`,
data: undefined,
});
}

View File

@ -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<UpdatePaymentMethodByIdResponseDTO> {
return dataSource.updateOne<UpdatePaymentMethodByIdRequestDTO, UpdatePaymentMethodByIdResponseDTO>(
"catalogs/payment-methods",
params.paymentMethodId,
params.data
);
}

View File

@ -13,4 +13,5 @@ export interface PaymentMethodListRow {
isSystem: boolean; isSystem: boolean;
isActive: boolean; isActive: boolean;
isDefault: boolean;
} }

View File

@ -7,4 +7,5 @@ export interface PaymentMethod {
isSystem: boolean; isSystem: boolean;
isActive: boolean; isActive: boolean;
isDefault: boolean;
} }

View File

@ -1 +1,8 @@
export * from "./use-payment-method-list-query"; 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";

View File

@ -1,21 +1,15 @@
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import type { ListPaymentMethodsRequestDTO } from "../../../../common"; import type { ListPaymentMethodsRequestDTO } from "../../../../common";
/**
* Prefijo base para listados
*/
export const LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX = ["payment-methods"] as const; 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 => export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequestDTO): QueryKey =>
[ [
...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX, ...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
{ {
pageNumber: criteria?.pageNumber ?? 1, pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
pageSize: criteria?.pageSize ?? 5, pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
q: criteria?.q ?? "", q: criteria?.q ?? "",
filters: criteria?.filters ?? [], filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "", orderBy: criteria?.orderBy ?? "",
@ -23,22 +17,21 @@ export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequ
}, },
] as const; ] 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_METHODS_DETAIL_QUERY_KEY_PREFIX = ["payment-methods:detail"] as const;
export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [ export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [
...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX, ...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX,
{ paymentMethodId }, { paymentMethodId },
]; ];
/**
* Keys para mutaciones
*/
export const CREATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:create"] as const; 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 UPDATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:update"] as const;
export const DELETE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:delete"] 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;
/** export const paymentMethodKeys = {
* Operaciones de dominio all: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
*/ list: (criteria?: ListPaymentMethodsRequestDTO) => LIST_PAYMENT_METHODS_QUERY_KEY(criteria),
detail: (paymentMethodId?: string) => PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
};

View File

@ -0,0 +1,9 @@
import type { ValidationErrorDetail } from "@repo/rdx-ddd";
import type { ZodError } from "zod";
export const toValidationErrors = (error: ZodError<unknown>): ValidationErrorDetail[] =>
error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
value: issue.input,
}));

View File

@ -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<PaymentMethod, DefaultError, CreatePaymentMethodParams>({
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,
});
},
});
};

View File

@ -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<void, DefaultError, DeletePaymentMethodParams>({
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 }),
]);
},
});
};

View File

@ -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<PaymentMethod, DefaultError> => {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && Boolean(paymentMethodId);
return useQuery<PaymentMethod, DefaultError>({
queryKey: PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
enabled,
queryFn: async ({ signal }) => {
const dto = await getPaymentMethodById(dataSource, {
paymentMethodId: paymentMethodId as string,
signal,
});
return GetPaymentMethodByIdAdapter.fromDto(dto);
},
});
};

View File

@ -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<PaymentMethod, DefaultError, DisablePaymentMethodParams>({
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 });
},
});
};

View File

@ -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<PaymentMethod, DefaultError, EnablePaymentMethodParams>({
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 });
},
});
};

View File

@ -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<PaymentMethod, DefaultError, SetDefaultPaymentMethodParams>({
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) }),
]);
},
});
};

View File

@ -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<PaymentMethod, DefaultError, UpdatePaymentMethodParams>({
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,
});
},
});
};

View File

@ -1,2 +1,4 @@
export * from "./entities"; export * from "./entities";
export * from "./hooks"; export * from "./hooks";
export * from "./api";
export * from "./adapters";

View File

@ -0,0 +1 @@
export * from "./payment-methods";

View File

@ -0,0 +1,2 @@
export * from "./use-payment-methods-settings-controller";
export * from "./use-payment-methods-settings-page-controller";

View File

@ -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<string | null>(null);
const [deletingPaymentMethod, setDeletingPaymentMethod] = useState<PaymentMethodListRow | null>(
null
);
const editingPaymentMethodQuery = usePaymentMethodDetailQuery(
editingPaymentMethodId ?? undefined,
{
enabled: editingPaymentMethodId !== null,
}
);
const editingPaymentMethod = editingPaymentMethodQuery.data ?? null;
const form = useHookForm<PaymentMethodForm>({
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<PaymentMethodForm>) => {
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<HTMLFormElement>) => {
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,
};
};

View File

@ -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,
};
};

View File

@ -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";

View File

@ -0,0 +1,5 @@
export interface PaymentMethodForm {
name: string;
description: string;
isActive: boolean;
}

View File

@ -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<typeof PaymentMethodFormSchema>;

View File

@ -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;
}

View File

@ -0,0 +1,9 @@
import type { PageErrorStateVariant } from "@erp/core/components";
export interface PaymentMethodsSettingsPageErrorViewModel {
variant: PageErrorStateVariant;
title: string;
description: string;
reference?: string;
canRetry: boolean;
}

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -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 (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("settings.paymentMethods.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.paymentMethods.delete.description", {
name: target?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button disabled={isSubmitting} onClick={() => onOpenChange(false)} variant="outline">
{t("settings.paymentMethods.actions.cancel")}
</Button>
<Button disabled={isSubmitting || !target} onClick={onConfirm} variant="destructive">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{t("settings.paymentMethods.actions.deleting")}
</>
) : (
t("settings.paymentMethods.actions.delete")
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -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";

View File

@ -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<PaymentMethodForm>;
onSubmit: FormEventHandler<HTMLFormElement>;
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 (
<Dialog onOpenChange={openChange} open={open}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form className="space-y-6" id={formId} noValidate onSubmit={onSubmit}>
<RdxFormGrid className="grid-cols-1 md:grid-cols-1">
<TextField
autoComplete="off"
className="w-full"
description={t("settings.paymentMethods.fields.nameDescription")}
disabled={isSubmitting}
label={t("settings.paymentMethods.fields.name")}
name="name"
placeholder={t("settings.paymentMethods.fields.namePlaceholder")}
/>
<TextAreaField
className="w-full"
description={t("settings.paymentMethods.fields.descriptionDescription")}
disabled={isSubmitting}
label={t("settings.paymentMethods.fields.description")}
name="description"
placeholder={t("settings.paymentMethods.fields.descriptionPlaceholder")}
rows={4}
/>
<SwitchField
className="flex items-center justify-between rounded-lg border border-border px-4 py-3"
description={t("settings.paymentMethods.fields.activeDescription")}
disabled={isSubmitting || disableActiveField}
label={t("settings.paymentMethods.fields.active")}
name="isActive"
readOnly={disableActiveField}
/>
</RdxFormGrid>
</form>
</FormProvider>
<DialogFooter>
<Button disabled={isSubmitting} onClick={() => openChange(false)} variant="outline">
{t("settings.paymentMethods.actions.cancel")}
</Button>
<Button disabled={isSubmitting} form={formId} type="submit">
{isSubmitting ? (
<>
<Spinner aria-hidden className="mr-2 size-4" />
{isEditMode
? t("settings.paymentMethods.actions.saving")
: t("settings.paymentMethods.actions.creating")}
</>
) : (
submitLabel
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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 (
<div className="flex items-center gap-2">
<Button
aria-label={t("settings.paymentMethods.actions.edit")}
disabled={editDisabled}
onClick={() => onEdit(paymentMethod)}
size="sm"
title={
editDisabled ? t("settings.paymentMethods.protections.systemEdit") : undefined
}
variant="outline"
>
{t("settings.paymentMethods.actions.edit")}
</Button>
<Button
aria-label={
paymentMethod.isActive
? t("settings.paymentMethods.actions.disable")
: t("settings.paymentMethods.actions.enable")
}
disabled={toggleDisabled}
onClick={() => onToggleActive(paymentMethod)}
size="sm"
title={toggleDisabled ? activeDisabledReason : undefined}
variant="outline"
>
{paymentMethod.isActive
? t("settings.paymentMethods.actions.disable")
: t("settings.paymentMethods.actions.enable")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("settings.paymentMethods.actions.more")}
size="icon"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent
align="end"
className="w-48"
render={
<DropdownMenuItem
disabled={deleteDisabled}
onClick={() => onDelete(paymentMethod)}
title={deleteDisabled ? deleteDisabledReason : undefined}
variant="destructive"
>
{t("settings.paymentMethods.actions.delete")}
</DropdownMenuItem>
}
/>
</DropdownMenu>
</div>
);
};

View File

@ -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 (
<div className="rounded-xl border border-dashed border-border bg-muted/20 p-8 text-center">
<div className="space-y-2">
<h3 className="text-lg font-semibold">{t("settings.paymentMethods.empty.title")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.paymentMethods.empty.description")}
</p>
</div>
<Button className="mt-6" onClick={onCreate}>
{t("settings.paymentMethods.actions.create")}
</Button>
</div>
);
};

View File

@ -0,0 +1,15 @@
import { Skeleton } from "@repo/shadcn-ui/components";
export const PaymentMethodsSettingsSkeleton = () => {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-4 w-80" />
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./pages";
export * from "./components";

View File

@ -0,0 +1 @@
export * from "./payment-methods-settings-page";

View File

@ -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 <PaymentMethodsSettingsSkeleton />;
}
if (ctrl.pageError) {
return (
<RdxPageContainer width="full">
<div className="mx-auto w-full max-w-3xl">
<PageErrorState
description={ctrl.pageError.description}
primaryAction={
ctrl.pageError.canRetry
? {
label: ctrl.isRetrying
? t("settings.paymentMethods.pageError.actions.retrying")
: t("settings.paymentMethods.pageError.actions.retry"),
onClick: ctrl.retry,
isLoading: ctrl.isRetrying,
}
: undefined
}
reference={ctrl.pageError.reference}
title={ctrl.pageError.title}
variant={ctrl.pageError.variant}
/>
</div>
</RdxPageContainer>
);
}
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 (
<RdxPageContainer width="full">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight">
{t("settings.paymentMethods.title")}
</h2>
<p className="max-w-3xl text-sm text-muted-foreground">
{t("settings.paymentMethods.description")}
</p>
</div>
<Button onClick={featureCtrl.openCreateDialog}>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("settings.paymentMethods.actions.create")}
</Button>
</div>
{ctrl.list.length === 0 ? (
<PaymentMethodsEmptyState onCreate={featureCtrl.openCreateDialog} />
) : (
<RadioGroup
aria-label={t("settings.paymentMethods.default.label")}
className="space-y-3"
disabled={featureCtrl.isChangingDefault}
onValueChange={(paymentMethodId) => {
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) => (
<Card key={paymentMethod.id}>
<CardContent className="flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 items-start gap-4">
<RadioGroupItem
aria-label={t("settings.paymentMethods.default.rowLabel", {
name: paymentMethod.name,
})}
id={`payment-method-default-${paymentMethod.id}`}
value={paymentMethod.id}
/>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<label
className="cursor-pointer text-sm font-medium"
htmlFor={`payment-method-default-${paymentMethod.id}`}
>
{paymentMethod.name}
</label>
{paymentMethod.isSystem ? (
<Badge variant="secondary">
{t("settings.paymentMethods.status.system")}
</Badge>
) : null}
{paymentMethod.isDefault ? (
<Badge variant="outline">
{t("settings.paymentMethods.status.default")}
</Badge>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
{paymentMethod.isActive
? t("settings.paymentMethods.status.active")
: t("settings.paymentMethods.status.inactive")}
</span>
</div>
</div>
</div>
<PaymentMethodRowActions
activeDisabledReason={activeDisabledReason(
paymentMethod.isDefault,
paymentMethod.isSystem
)}
deleteDisabledReason={deleteDisabledReason(
paymentMethod.isDefault,
paymentMethod.isSystem
)}
onDelete={featureCtrl.openDeleteDialog}
onEdit={featureCtrl.openEditDialog}
onToggleActive={(item) => {
if (item.isActive) {
featureCtrl.disable(item);
return;
}
featureCtrl.enable(item);
}}
paymentMethod={paymentMethod}
/>
</CardContent>
</Card>
))}
</RadioGroup>
)}
<PaymentMethodFormDialog
description={t("settings.paymentMethods.create.description")}
form={featureCtrl.form}
formId={featureCtrl.formId}
isEditMode={false}
isSubmitting={featureCtrl.isSaving}
onSubmit={featureCtrl.onSubmit}
open={featureCtrl.isCreateOpen}
openChange={(open) => {
if (!open) {
featureCtrl.closeCreateDialog();
}
}}
submitLabel={t("settings.paymentMethods.actions.create")}
title={t("settings.paymentMethods.create.title")}
/>
<PaymentMethodFormDialog
description={t("settings.paymentMethods.edit.description")}
disableActiveField={featureCtrl.editingPaymentMethod?.isDefault === true}
form={featureCtrl.form}
formId={`${featureCtrl.formId}-edit`}
isEditMode
isSubmitting={featureCtrl.isSaving || featureCtrl.isEditingPaymentMethodLoading}
onSubmit={featureCtrl.onSubmit}
open={Boolean(featureCtrl.editingPaymentMethod) || featureCtrl.isEditingPaymentMethodLoading}
openChange={(open) => {
if (!open) {
featureCtrl.closeEditDialog();
}
}}
submitLabel={t("settings.paymentMethods.actions.save")}
title={t("settings.paymentMethods.edit.title")}
/>
<DeletePaymentMethodDialog
isSubmitting={featureCtrl.isDeleting}
onConfirm={featureCtrl.confirmDelete}
onOpenChange={(open) => {
if (!open) {
featureCtrl.closeDeleteDialog();
}
}}
open={Boolean(featureCtrl.deletingPaymentMethod)}
target={featureCtrl.deletingPaymentMethod}
/>
</div>
</RdxPageContainer>
);
};

View File

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

Some files were not shown because too many files have changed in this diff Show More