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",
"description": "Configure the behavior of the active company.",
"sections": {
"general": "General",
"company": "Company",
"billing": "Billing"
},

View File

@ -38,6 +38,7 @@
"title": "Configuración",
"description": "Configura el comportamiento de la empresa activa.",
"sections": {
"general": "General",
"company": "Empresa",
"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", () => {
assert.throws(
() =>

View File

@ -47,6 +47,9 @@ const renderSettingsRouter = async ({
companyDetails: {
title: "Datos de empresa",
},
paymentMethods: {
title: "Métodos de pago",
},
},
});
await i18n.changeLanguage("es");
@ -117,6 +120,37 @@ test("resuelve un deep link modular bajo /settings", async () => {
assert.match(html, /Contenido real de configuracion/);
});
test("resuelve un deep link modular bajo /settings/general/payment-methods", async () => {
const html = await renderSettingsRouter({
initialEntry: "/settings/general/payment-methods",
modules: [
createModule({
name: SETTINGS_ROUTE_TEST_MODULE,
settings: {
entries: [
{
id: "payment-methods",
sectionId: "general",
labelKey: "settings.paymentMethods.title",
path: "/settings/general/payment-methods",
},
],
},
routes: [
{
path: "settings/general/payment-methods",
element: <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 () => {
const html = await renderSettingsRouter({
initialEntry: "/settings/unknown/path",

View File

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

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 IPaymentMethodDeleter, PaymentMethodDeleter } from "../services";
import type { IPaymentMethodUsageChecker } from "../services";
export const buildPaymentMethodDeleter = (params: {
repository: IPaymentMethodRepository;
usageChecker: IPaymentMethodUsageChecker;
}): IPaymentMethodDeleter => {
const { repository } = params;
const { repository, usageChecker } = params;
return new PaymentMethodDeleter(repository);
return new PaymentMethodDeleter({
repository,
usageChecker,
});
};

View File

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

View File

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

View File

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

View File

@ -8,11 +8,11 @@ export class PaymentMethodPublicModelMapper {
public toPublicModel(paymentMethod: PaymentMethod): PaymentMethodPublicModel {
return {
id: paymentMethod.id,
companyId: paymentMethod.companyId,
companyId: paymentMethod.companyId,
name: paymentMethod.name.toPrimitive(),
description: paymentMethod.description.map((value) => value.toPrimitive()),
isActive: paymentMethod.isActive,
isDefault: paymentMethod.isDefault,
};
}
}

View File

@ -9,6 +9,5 @@ export interface PaymentMethodPublicModel {
description: Maybe<string>;
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 { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { PaymentMethod } from "../../../domain";
import type { PaymentMethod, PaymentMethodSystemKey } from "../../../domain";
import type { PaymentMethodSummary } from "../models";
export interface IPaymentMethodRepository {
@ -28,6 +28,33 @@ export interface IPaymentMethodRepository {
criteria: Criteria,
transaction?: unknown
): Promise<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(
companyId: 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 "./bootstrap-system-payment-methods.service";
export * from "./payment-method-deleter";
export * from "./payment-method-disabler";
export * from "./payment-method-enabler";
export * from "./payment-method-finder";
export * from "./payment-method-public-services";
export * from "./payment-method-usage-checker";
export * from "./payment-method-status-changer";
export * from "./payment-method-updater";
export * from "./set-default-payment-method.service";

View File

@ -1,8 +1,9 @@
import { Result } from "@repo/rdx-utils";
import type { PaymentMethod } from "../../../domain";
import { PaymentMethodCannotBeDeletedError } from "../../../domain";
import { PaymentMethodInUseError } from "../../../domain";
import type { IPaymentMethodRepository } from "../repositories";
import type { IPaymentMethodUsageChecker } from "./payment-method-usage-checker";
export interface IPaymentMethodDeleter {
delete(params: {
@ -12,7 +13,12 @@ export interface IPaymentMethodDeleter {
}
export class PaymentMethodDeleter implements IPaymentMethodDeleter {
public constructor(private readonly repository: IPaymentMethodRepository) {}
public constructor(
private readonly deps: {
repository: IPaymentMethodRepository;
usageChecker: IPaymentMethodUsageChecker;
}
) {}
public async delete(params: {
paymentMethod: PaymentMethod;
@ -20,13 +26,31 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter {
}): Promise<Result<PaymentMethod, Error>> {
const { paymentMethod, transaction } = params;
if (paymentMethod.isSystem) {
const invariantResult = paymentMethod.ensureCanBeDeleted();
if (invariantResult.isFailure) {
return Result.fail(invariantResult.error);
}
const usageResult = await this.deps.usageChecker.findUsage({
companyId: paymentMethod.companyId,
paymentMethodId: paymentMethod.id,
transaction,
});
if (usageResult.isFailure) {
return Result.fail(usageResult.error);
}
if (usageResult.data.length > 0) {
const sources = usageResult.data.map((detail) => detail.source).join(", ");
return Result.fail(
new PaymentMethodCannotBeDeletedError("System payment methods cannot be deleted.")
new PaymentMethodInUseError(
`Payment method cannot be deleted because it is still referenced by: ${sources}.`
)
);
}
const deleteResult = await this.repository.deleteByIdInCompany(
const deleteResult = await this.deps.repository.deleteByIdInCompany(
paymentMethod.companyId,
paymentMethod.id,
transaction

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_system: paymentMethod.isSystem,
is_default: paymentMethod.isDefault,
};
}
}

View File

@ -16,6 +16,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar
is_system: paymentMethod.isSystem,
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 "./delete-payment-method-by-id.use-case";
export * from "./disable-payment-method-by-id.use-case";
export * from "./enable-payment-method-by-id.use-case";
export * from "./get-payment-method-by-id.use-case";
export * from "./list-payment-methods.use-case";
export * from "./set-default-payment-method-by-id.use-case";
export * from "./update-payment-method-by-id.use-case";

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 {
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DISABLED" as const;
}
@ -47,6 +56,16 @@ export const isPaymentMethodCannotBeDisabledError = (
//
export class PaymentMethodCannotBeDefaultError extends DomainError {
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DEFAULT" as const;
}
export const isPaymentMethodCannotBeDefaultError = (
e: unknown
): e is PaymentMethodCannotBeDefaultError => e instanceof PaymentMethodCannotBeDefaultError;
//
export class PaymentMethodCannotBeEnabledError extends DomainError {
public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const;
}

View File

@ -1,3 +1,4 @@
export * from "./errors";
export * from "./payment-method.aggregate";
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 { type Maybe, Result } from "@repo/rdx-utils";
import { PaymentMethodCannotBeDisabledError, PaymentMethodCannotBeUpdatedError } from "./errors";
import {
PaymentMethodCannotBeDefaultError,
PaymentMethodCannotBeDisabledError,
PaymentMethodCannotBeDeletedError,
PaymentMethodCannotBeUpdatedError,
} from "./errors";
import type { PaymentMethodSystemKey } from "./payment-method-system-key";
export interface IPaymentMethodCreateProps {
companyId: UniqueID;
@ -9,6 +15,8 @@ export interface IPaymentMethodCreateProps {
description: Maybe<TextValue>;
isActive: boolean;
isSystem: boolean;
isDefault: boolean;
systemKey: Maybe<PaymentMethodSystemKey>;
}
export type PaymentMethodPatchProps = Partial<{
@ -57,6 +65,20 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
return Result.fail(new Error("Payment method name is required"));
}
if (props.isDefault && !props.isActive) {
return Result.fail(
new PaymentMethodCannotBeDefaultError("Default payment methods must remain active.")
);
}
if (props.isSystem && props.systemKey.isNone()) {
return Result.fail(new Error("System payment methods require a system key."));
}
if (!props.isSystem && props.systemKey.isSome()) {
return Result.fail(new Error("Custom payment methods cannot define a system key."));
}
return Result.ok();
}
@ -88,6 +110,14 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
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> {
if (this.isSystem) {
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) {
return Result.ok(false);
}
@ -156,6 +194,50 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
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() {
return {
id: this.id.toPrimitive(),
@ -167,6 +249,7 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
),
is_active: this.isActive,
is_system: this.isSystem,
is_default: this.isDefault,
};
}

View File

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

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 "./get-payment-method-by-id.controller";
export * from "./list-payment-methods.controller";
export * from "./bootstrap-system-payment-methods.controller";
export * from "./set-default-payment-method-by-id.controller";
export * from "./update-payment-method-by-id.controller";

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 {
type InvalidPaymentMethodIdError,
type InvalidPaymentMethodNameError,
type PaymentMethodCannotBeDefaultError,
type PaymentMethodCannotBeDeletedError,
type PaymentMethodCannotBeDisabledError,
type PaymentMethodCannotBeEnabledError,
type PaymentMethodCannotBeUpdatedError,
type PaymentMethodInUseError,
type PaymentMethodNotFoundError,
isInvalidPaymentMethodIdError,
isInvalidPaymentMethodNameError,
isPaymentMethodCannotBeDefaultError,
isPaymentMethodCannotBeDeletedError,
isPaymentMethodCannotBeDisabledError,
isPaymentMethodCannotBeEnabledError,
isPaymentMethodCannotBeUpdatedError,
isPaymentMethodInUseError,
isPaymentMethodNotFoundError,
} from "../../../domain/payment-methods";
@ -60,6 +64,16 @@ const paymentMethodCannotBeDeletedRule: ErrorToApiRule = {
),
};
const paymentMethodCannotBeDefaultRule: ErrorToApiRule = {
priority: 120,
matches: isPaymentMethodCannotBeDefaultError,
build: (error) =>
new ValidationApiError(
(error as PaymentMethodCannotBeDefaultError).message ||
"Payment method cannot be marked as default."
),
};
const paymentMethodCannotBeDisabledRule: ErrorToApiRule = {
priority: 120,
matches: isPaymentMethodCannotBeDisabledError,
@ -87,11 +101,22 @@ const paymentMethodCannotBeUpdatedRule: ErrorToApiRule = {
),
};
const paymentMethodInUseRule: ErrorToApiRule = {
priority: 120,
matches: isPaymentMethodInUseError,
build: (error) =>
new ConflictApiError(
(error as PaymentMethodInUseError).message || "Payment method is currently in use."
),
};
export const paymentMethodsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(invalidPaymentMethodIdRule)
.register(invalidPaymentMethodNameRule)
.register(paymentMethodNotFoundRule)
.register(paymentMethodCannotBeDefaultRule)
.register(paymentMethodCannotBeDeletedRule)
.register(paymentMethodCannotBeDisabledRule)
.register(paymentMethodCannotBeEnabledRule)
.register(paymentMethodCannotBeUpdatedRule);
.register(paymentMethodCannotBeUpdatedRule)
.register(paymentMethodInUseRule);

View File

@ -3,22 +3,26 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
BootstrapSystemPaymentMethodsRequestSchema,
CreatePaymentMethodRequestSchema,
DeletePaymentMethodByIdRequestSchema,
GetPaymentMethodByIdRequestSchema,
ListPaymentMethodsRequestSchema,
SetDefaultPaymentMethodByIdRequestSchema,
UpdatePaymentMethodByIdParamsRequestSchema,
UpdatePaymentMethodByIdRequestSchema,
} from "../../../../common";
import type { CatalogsInternalDeps } from "../../di/catalogs.di";
import {
BootstrapSystemPaymentMethodsController,
CreatePaymentMethodController,
DeletePaymentMethodByIdController,
DisablePaymentMethodByIdController,
EnablePaymentMethodByIdController,
GetPaymentMethodByIdController,
ListPaymentMethodsController,
SetDefaultPaymentMethodByIdController,
UpdatePaymentMethodByIdController,
} from "./controllers";
@ -43,6 +47,17 @@ export const paymentMethodsRouter = (params: StartParams) => {
}
);
router.post(
"/bootstrap-system",
validateRequest(BootstrapSystemPaymentMethodsRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new BootstrapSystemPaymentMethodsController(
deps.useCases.bootstrapSystemPaymentMethods()
);
return controller.execute(req, res, next);
}
);
router.post(
"/",
validateRequest(CreatePaymentMethodRequestSchema, "body"),
@ -84,6 +99,17 @@ export const paymentMethodsRouter = (params: StartParams) => {
}
);
router.patch(
"/:payment_method_id/default",
validateRequest(SetDefaultPaymentMethodByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new SetDefaultPaymentMethodByIdController(
deps.useCases.setDefaultPaymentMethodById()
);
return controller.execute(req, res, next);
}
);
router.patch(
"/:payment_method_id/disable",
validateRequest(GetPaymentMethodByIdRequestSchema, "params"),

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import type { Sequelize, Transaction } from "sequelize";
import type { IPaymentMethodRepository } from "../../../../../application";
import type { PaymentMethodSummary } from "../../../../../application/payment-methods/models";
import type { PaymentMethod } from "../../../../../domain";
import type { PaymentMethod, PaymentMethodSystemKey } from "../../../../../domain";
import type { SequelizePaymentMethodSummaryMapper } from "../mappers";
import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper";
import { PaymentMethodModel } from "../models";
@ -174,6 +174,138 @@ export class SequelizePaymentMethodRepository
}
}
public async findDefaultInCompany(
companyId: UniqueID,
transaction?: Transaction
): Promise<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.).
*

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 "./bootstrap-system-payment-methods.request.dto";
export * from "./delete-payment-method-by-id.request.dto";
export * from "./get-payment-method-by-id.request.dto";
export * from "./list-payment-methods.request.dto";
export * from "./set-default-payment-method-by-id.request.dto";
export * from "./update-payment-method-by-id.request.dto";

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 "./bootstrap-system-payment-methods.response.dto";
export * from "./disable-payment-method-by-id.response.dto";
export * from "./get-payment-method-by-id.response.dto";
export * from "./list-payment-methods.response.dto";
export * from "./set-default-payment-method-by-id.response.dto";
export * from "./update-payment-method-by-id.response.dto";

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(),
is_active: z.boolean(),
is_system: z.boolean(),
is_default: z.boolean(),
});
export type PaymentMethodDetailDTO = z.infer<typeof PaymentMethodDetailSchema>;

View File

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

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

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 "./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;
isActive: boolean;
isDefault: boolean;
}

View File

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

View File

@ -1 +1,8 @@
export * from "./use-payment-method-list-query";
export * from "./use-payment-method-detail-query";
export * from "./use-payment-method-create-mutation";
export * from "./use-payment-method-update-mutation";
export * from "./use-payment-method-delete-mutation";
export * from "./use-payment-method-enable-mutation";
export * from "./use-payment-method-disable-mutation";
export * from "./use-payment-method-set-default-mutation";

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 { ListPaymentMethodsRequestDTO } from "../../../../common";
/**
* Prefijo base para listados
*/
export const LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX = ["payment-methods"] as const;
/**
* Query key para listado de payment methods
*/
export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequestDTO): QueryKey =>
[
...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
{
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5,
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
q: criteria?.q ?? "",
filters: criteria?.filters ?? [],
orderBy: criteria?.orderBy ?? "",
@ -23,22 +17,21 @@ export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequ
},
] as const;
/**
* Query key para detalle de payment method
*/
export const PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX = ["payment-methods:detail"] as const;
export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [
...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX,
{ paymentMethodId },
];
/**
* Keys para mutaciones
*/
export const CREATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:create"] as const;
export const UPDATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:update"] as const;
export const DELETE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:delete"] as const;
export const ENABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:enable"] as const;
export const DISABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:disable"] as const;
export const SET_DEFAULT_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:set-default"] as const;
/**
* Operaciones de dominio
*/
export const paymentMethodKeys = {
all: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
list: (criteria?: ListPaymentMethodsRequestDTO) => LIST_PAYMENT_METHODS_QUERY_KEY(criteria),
detail: (paymentMethodId?: string) => PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
};

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 "./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