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:
parent
45200284ed
commit
5dfbdd2a1c
@ -38,6 +38,7 @@
|
||||
"title": "Settings",
|
||||
"description": "Configure the behavior of the active company.",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"company": "Company",
|
||||
"billing": "Billing"
|
||||
},
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"title": "Configuración",
|
||||
"description": "Configura el comportamiento de la empresa activa.",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"company": "Empresa",
|
||||
"billing": "Facturación"
|
||||
},
|
||||
|
||||
@ -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(
|
||||
() =>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
121
docs/catalogs/payment-methods-design.md
Normal file
121
docs/catalogs/payment-methods-design.md
Normal 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
|
||||
3
docs/catalogs/sql/README.md
Normal file
3
docs/catalogs/sql/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Catalogs SQL
|
||||
|
||||
Scripts manuales e idempotentes para evolución del módulo `catalogs`.
|
||||
@ -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;
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -8,4 +8,5 @@ export type PaymentMethodDetail = {
|
||||
description: Maybe<TextValue>;
|
||||
is_active: boolean;
|
||||
is_system: boolean;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
@ -6,4 +6,5 @@ export type PaymentMethodSummary = {
|
||||
name: Name;
|
||||
isActive: boolean;
|
||||
isSystem: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -9,6 +9,5 @@ export interface PaymentMethodPublicModel {
|
||||
description: Maybe<string>;
|
||||
|
||||
isActive: boolean;
|
||||
|
||||
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ export class PaymentMethodFullSnapshotBuilder implements IPaymentMethodFullSnaps
|
||||
|
||||
is_active: paymentMethod.isActive,
|
||||
is_system: paymentMethod.isSystem,
|
||||
is_default: paymentMethod.isDefault,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar
|
||||
|
||||
is_system: paymentMethod.isSystem,
|
||||
is_active: paymentMethod.isActive,
|
||||
is_default: paymentMethod.isDefault,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./errors";
|
||||
export * from "./payment-method.aggregate";
|
||||
export * from "./payment-method-name";
|
||||
export * from "./payment-method-system-key";
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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.).
|
||||
*
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const BootstrapSystemPaymentMethodsRequestSchema = z.object({});
|
||||
|
||||
export type BootstrapSystemPaymentMethodsRequestDTO = z.infer<
|
||||
typeof BootstrapSystemPaymentMethodsRequestSchema
|
||||
>;
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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
|
||||
>;
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
116
modules/catalogs/src/common/locales/en.json
Normal file
116
modules/catalogs/src/common/locales/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
modules/catalogs/src/common/locales/es.json
Normal file
116
modules/catalogs/src/common/locales/es.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
modules/catalogs/src/web/catalogs-routes.tsx
Normal file
22
modules/catalogs/src/web/catalogs-routes.tsx
Normal 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 />,
|
||||
},
|
||||
];
|
||||
};
|
||||
35
modules/catalogs/src/web/i18n.ts
Normal file
35
modules/catalogs/src/web/i18n.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
26
modules/catalogs/src/web/manifest.test.ts
Normal file
26
modules/catalogs/src/web/manifest.test.ts
Normal 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);
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from './list-payment-methods.adapter';
|
||||
export * from "./list-payment-methods.adapter";
|
||||
export * from "./get-payment-method-by-id.adapter";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -13,4 +13,5 @@ export interface PaymentMethodListRow {
|
||||
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
@ -7,4 +7,5 @@ export interface PaymentMethod {
|
||||
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./entities";
|
||||
export * from "./hooks";
|
||||
export * from "./api";
|
||||
export * from "./adapters";
|
||||
|
||||
1
modules/catalogs/src/web/settings/index.ts
Normal file
1
modules/catalogs/src/web/settings/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./payment-methods";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-payment-methods-settings-controller";
|
||||
export * from "./use-payment-methods-settings-page-controller";
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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";
|
||||
@ -0,0 +1,5 @@
|
||||
export interface PaymentMethodForm {
|
||||
name: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
@ -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>;
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import type { PageErrorStateVariant } from "@erp/core/components";
|
||||
|
||||
export interface PaymentMethodsSettingsPageErrorViewModel {
|
||||
variant: PageErrorStateVariant;
|
||||
title: string;
|
||||
description: string;
|
||||
reference?: string;
|
||||
canRetry: boolean;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./pages";
|
||||
export * from "./components";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./payment-methods-settings-page";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
Loading…
Reference in New Issue
Block a user