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",
|
"title": "Settings",
|
||||||
"description": "Configure the behavior of the active company.",
|
"description": "Configure the behavior of the active company.",
|
||||||
"sections": {
|
"sections": {
|
||||||
|
"general": "General",
|
||||||
"company": "Company",
|
"company": "Company",
|
||||||
"billing": "Billing"
|
"billing": "Billing"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"title": "Configuración",
|
"title": "Configuración",
|
||||||
"description": "Configura el comportamiento de la empresa activa.",
|
"description": "Configura el comportamiento de la empresa activa.",
|
||||||
"sections": {
|
"sections": {
|
||||||
|
"general": "General",
|
||||||
"company": "Empresa",
|
"company": "Empresa",
|
||||||
"billing": "Facturación"
|
"billing": "Facturación"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -123,6 +123,43 @@ test("agrega entries de dos módulos en la misma sección", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("incluye entries publicadas en la sección general", () => {
|
||||||
|
const result = buildSettingsNavigation({
|
||||||
|
modules: [
|
||||||
|
createModule({
|
||||||
|
name: "Catalogs",
|
||||||
|
settings: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "payment-methods",
|
||||||
|
sectionId: "general",
|
||||||
|
labelKey: "settings.paymentMethods.title",
|
||||||
|
path: "/settings/general/payment-methods",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
sections: settingsSectionCatalog,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
labelKey: "web.settings.sections.general",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "payment-methods",
|
||||||
|
sectionId: "general",
|
||||||
|
labelKey: "settings.paymentMethods.title",
|
||||||
|
path: "/settings/general/payment-methods",
|
||||||
|
moduleName: "Catalogs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("falla si un módulo declara una sección inexistente", () => {
|
test("falla si un módulo declara una sección inexistente", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -47,6 +47,9 @@ const renderSettingsRouter = async ({
|
|||||||
companyDetails: {
|
companyDetails: {
|
||||||
title: "Datos de empresa",
|
title: "Datos de empresa",
|
||||||
},
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
title: "Métodos de pago",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await i18n.changeLanguage("es");
|
await i18n.changeLanguage("es");
|
||||||
@ -117,6 +120,37 @@ test("resuelve un deep link modular bajo /settings", async () => {
|
|||||||
assert.match(html, /Contenido real de configuracion/);
|
assert.match(html, /Contenido real de configuracion/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("resuelve un deep link modular bajo /settings/general/payment-methods", async () => {
|
||||||
|
const html = await renderSettingsRouter({
|
||||||
|
initialEntry: "/settings/general/payment-methods",
|
||||||
|
modules: [
|
||||||
|
createModule({
|
||||||
|
name: SETTINGS_ROUTE_TEST_MODULE,
|
||||||
|
settings: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "payment-methods",
|
||||||
|
sectionId: "general",
|
||||||
|
labelKey: "settings.paymentMethods.title",
|
||||||
|
path: "/settings/general/payment-methods",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "settings/general/payment-methods",
|
||||||
|
element: <div>Contenido real de metodos de pago</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(html, /Configuración/);
|
||||||
|
assert.match(html, /Métodos de pago/);
|
||||||
|
assert.match(html, /Contenido real de metodos de pago/);
|
||||||
|
});
|
||||||
|
|
||||||
test("muestra 404 para una ruta inválida bajo /settings", async () => {
|
test("muestra 404 para una ruta inválida bajo /settings", async () => {
|
||||||
const html = await renderSettingsRouter({
|
const html = await renderSettingsRouter({
|
||||||
initialEntry: "/settings/unknown/path",
|
initialEntry: "/settings/unknown/path",
|
||||||
|
|||||||
@ -4,6 +4,10 @@ export interface SettingsSectionCatalogItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [
|
export const settingsSectionCatalog: SettingsSectionCatalogItem[] = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
labelKey: "web.settings.sections.general",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "company",
|
id: "company",
|
||||||
labelKey: "web.settings.sections.company",
|
labelKey: "web.settings.sections.company",
|
||||||
|
|||||||
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 { IPaymentMethodRepository } from "../repositories";
|
||||||
import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services";
|
import { type IPaymentMethodDeleter, PaymentMethodDeleter } from "../services";
|
||||||
|
import type { IPaymentMethodUsageChecker } from "../services";
|
||||||
|
|
||||||
export const buildPaymentMethodDeleter = (params: {
|
export const buildPaymentMethodDeleter = (params: {
|
||||||
repository: IPaymentMethodRepository;
|
repository: IPaymentMethodRepository;
|
||||||
|
usageChecker: IPaymentMethodUsageChecker;
|
||||||
}): IPaymentMethodDeleter => {
|
}): IPaymentMethodDeleter => {
|
||||||
const { repository } = params;
|
const { repository, usageChecker } = params;
|
||||||
|
|
||||||
return new PaymentMethodDeleter(repository);
|
return new PaymentMethodDeleter({
|
||||||
|
repository,
|
||||||
|
usageChecker,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
extractOrPushError,
|
extractOrPushError,
|
||||||
maybeFromNullableResult,
|
maybeFromNullableResult,
|
||||||
} from "@repo/rdx-ddd";
|
} from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { CreatePaymentMethodRequestDTO } from "../../../../common";
|
import type { CreatePaymentMethodRequestDTO } from "../../../../common";
|
||||||
import type { IPaymentMethodCreateProps } from "../../../domain";
|
import type { IPaymentMethodCreateProps } from "../../../domain";
|
||||||
@ -48,6 +48,8 @@ export class CreatePaymentMethodInputMapper implements ICreatePaymentMethodInput
|
|||||||
description: description!,
|
description: description!,
|
||||||
isActive: isActive!,
|
isActive: isActive!,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
isDefault: false,
|
||||||
|
systemKey: Maybe.none(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export type PaymentMethodDetail = {
|
|||||||
description: Maybe<TextValue>;
|
description: Maybe<TextValue>;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
|
is_default: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,4 +6,5 @@ export type PaymentMethodSummary = {
|
|||||||
name: Name;
|
name: Name;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export class PaymentMethodPublicModelMapper {
|
|||||||
name: paymentMethod.name.toPrimitive(),
|
name: paymentMethod.name.toPrimitive(),
|
||||||
description: paymentMethod.description.map((value) => value.toPrimitive()),
|
description: paymentMethod.description.map((value) => value.toPrimitive()),
|
||||||
isActive: paymentMethod.isActive,
|
isActive: paymentMethod.isActive,
|
||||||
|
isDefault: paymentMethod.isDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,5 @@ export interface PaymentMethodPublicModel {
|
|||||||
description: Maybe<string>;
|
description: Maybe<string>;
|
||||||
|
|
||||||
isActive: boolean;
|
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 { UniqueID } from "@repo/rdx-ddd";
|
||||||
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { PaymentMethod } from "../../../domain";
|
import type { PaymentMethod, PaymentMethodSystemKey } from "../../../domain";
|
||||||
import type { PaymentMethodSummary } from "../models";
|
import type { PaymentMethodSummary } from "../models";
|
||||||
|
|
||||||
export interface IPaymentMethodRepository {
|
export interface IPaymentMethodRepository {
|
||||||
@ -28,6 +28,33 @@ export interface IPaymentMethodRepository {
|
|||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
transaction?: unknown
|
transaction?: unknown
|
||||||
): Promise<Result<Collection<PaymentMethodSummary>, Error>>;
|
): Promise<Result<Collection<PaymentMethodSummary>, Error>>;
|
||||||
|
findDefaultInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<Maybe<PaymentMethod>, Error>>;
|
||||||
|
findBySystemKeyInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
systemKey: PaymentMethodSystemKey,
|
||||||
|
transaction?: unknown,
|
||||||
|
options?: { includeDeleted?: boolean }
|
||||||
|
): Promise<Result<Maybe<PaymentMethod>, Error>>;
|
||||||
|
restoreByIdInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
id: UniqueID,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<boolean, Error>>;
|
||||||
|
syncSystemPaymentMethodState(params: {
|
||||||
|
companyId: UniqueID;
|
||||||
|
id: UniqueID;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
systemKey: PaymentMethodSystemKey;
|
||||||
|
transaction?: unknown;
|
||||||
|
}): Promise<Result<void, Error>>;
|
||||||
|
lockByCompanyId(
|
||||||
|
companyId: UniqueID,
|
||||||
|
transaction?: unknown
|
||||||
|
): Promise<Result<void, Error>>;
|
||||||
deleteByIdInCompany(
|
deleteByIdInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
id: UniqueID,
|
id: UniqueID,
|
||||||
|
|||||||
@ -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 "./payment-method-creator";
|
||||||
|
export * from "./bootstrap-system-payment-methods.service";
|
||||||
export * from "./payment-method-deleter";
|
export * from "./payment-method-deleter";
|
||||||
export * from "./payment-method-disabler";
|
export * from "./payment-method-disabler";
|
||||||
export * from "./payment-method-enabler";
|
export * from "./payment-method-enabler";
|
||||||
export * from "./payment-method-finder";
|
export * from "./payment-method-finder";
|
||||||
export * from "./payment-method-public-services";
|
export * from "./payment-method-public-services";
|
||||||
|
export * from "./payment-method-usage-checker";
|
||||||
export * from "./payment-method-status-changer";
|
export * from "./payment-method-status-changer";
|
||||||
export * from "./payment-method-updater";
|
export * from "./payment-method-updater";
|
||||||
|
export * from "./set-default-payment-method.service";
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { PaymentMethod } from "../../../domain";
|
import type { PaymentMethod } from "../../../domain";
|
||||||
import { PaymentMethodCannotBeDeletedError } from "../../../domain";
|
import { PaymentMethodInUseError } from "../../../domain";
|
||||||
import type { IPaymentMethodRepository } from "../repositories";
|
import type { IPaymentMethodRepository } from "../repositories";
|
||||||
|
import type { IPaymentMethodUsageChecker } from "./payment-method-usage-checker";
|
||||||
|
|
||||||
export interface IPaymentMethodDeleter {
|
export interface IPaymentMethodDeleter {
|
||||||
delete(params: {
|
delete(params: {
|
||||||
@ -12,7 +13,12 @@ export interface IPaymentMethodDeleter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PaymentMethodDeleter implements IPaymentMethodDeleter {
|
export class PaymentMethodDeleter implements IPaymentMethodDeleter {
|
||||||
public constructor(private readonly repository: IPaymentMethodRepository) {}
|
public constructor(
|
||||||
|
private readonly deps: {
|
||||||
|
repository: IPaymentMethodRepository;
|
||||||
|
usageChecker: IPaymentMethodUsageChecker;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
public async delete(params: {
|
public async delete(params: {
|
||||||
paymentMethod: PaymentMethod;
|
paymentMethod: PaymentMethod;
|
||||||
@ -20,13 +26,31 @@ export class PaymentMethodDeleter implements IPaymentMethodDeleter {
|
|||||||
}): Promise<Result<PaymentMethod, Error>> {
|
}): Promise<Result<PaymentMethod, Error>> {
|
||||||
const { paymentMethod, transaction } = params;
|
const { paymentMethod, transaction } = params;
|
||||||
|
|
||||||
if (paymentMethod.isSystem) {
|
const invariantResult = paymentMethod.ensureCanBeDeleted();
|
||||||
|
if (invariantResult.isFailure) {
|
||||||
|
return Result.fail(invariantResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageResult = await this.deps.usageChecker.findUsage({
|
||||||
|
companyId: paymentMethod.companyId,
|
||||||
|
paymentMethodId: paymentMethod.id,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usageResult.isFailure) {
|
||||||
|
return Result.fail(usageResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usageResult.data.length > 0) {
|
||||||
|
const sources = usageResult.data.map((detail) => detail.source).join(", ");
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
new PaymentMethodCannotBeDeletedError("System payment methods cannot be deleted.")
|
new PaymentMethodInUseError(
|
||||||
|
`Payment method cannot be deleted because it is still referenced by: ${sources}.`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteResult = await this.repository.deleteByIdInCompany(
|
const deleteResult = await this.deps.repository.deleteByIdInCompany(
|
||||||
paymentMethod.companyId,
|
paymentMethod.companyId,
|
||||||
paymentMethod.id,
|
paymentMethod.id,
|
||||||
transaction
|
transaction
|
||||||
|
|||||||
@ -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_active: paymentMethod.isActive,
|
||||||
is_system: paymentMethod.isSystem,
|
is_system: paymentMethod.isSystem,
|
||||||
|
is_default: paymentMethod.isDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export class PaymentMethodSummarySnapshotBuilder implements IPaymentMethodSummar
|
|||||||
|
|
||||||
is_system: paymentMethod.isSystem,
|
is_system: paymentMethod.isSystem,
|
||||||
is_active: paymentMethod.isActive,
|
is_active: paymentMethod.isActive,
|
||||||
|
is_default: paymentMethod.isDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 "./create-payment-method.use-case";
|
||||||
export * from "./delete-payment-method-by-id.use-case";
|
export * from "./delete-payment-method-by-id.use-case";
|
||||||
export * from "./disable-payment-method-by-id.use-case";
|
export * from "./disable-payment-method-by-id.use-case";
|
||||||
export * from "./enable-payment-method-by-id.use-case";
|
export * from "./enable-payment-method-by-id.use-case";
|
||||||
export * from "./get-payment-method-by-id.use-case";
|
export * from "./get-payment-method-by-id.use-case";
|
||||||
export * from "./list-payment-methods.use-case";
|
export * from "./list-payment-methods.use-case";
|
||||||
|
export * from "./set-default-payment-method-by-id.use-case";
|
||||||
export * from "./update-payment-method-by-id.use-case";
|
export * from "./update-payment-method-by-id.use-case";
|
||||||
|
|||||||
@ -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 {
|
export class PaymentMethodCannotBeDisabledError extends DomainError {
|
||||||
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DISABLED" as const;
|
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DISABLED" as const;
|
||||||
}
|
}
|
||||||
@ -47,6 +56,16 @@ export const isPaymentMethodCannotBeDisabledError = (
|
|||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
|
export class PaymentMethodCannotBeDefaultError extends DomainError {
|
||||||
|
public readonly code = "PAYMENT_METHOD_CANNOT_BE_DEFAULT" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPaymentMethodCannotBeDefaultError = (
|
||||||
|
e: unknown
|
||||||
|
): e is PaymentMethodCannotBeDefaultError => e instanceof PaymentMethodCannotBeDefaultError;
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
export class PaymentMethodCannotBeEnabledError extends DomainError {
|
export class PaymentMethodCannotBeEnabledError extends DomainError {
|
||||||
public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const;
|
public readonly code = "PAYMENT_METHOD_CANNOT_BE_ENABLED" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
export * from "./payment-method.aggregate";
|
export * from "./payment-method.aggregate";
|
||||||
export * from "./payment-method-name";
|
export * from "./payment-method-name";
|
||||||
|
export * from "./payment-method-system-key";
|
||||||
|
|||||||
@ -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 { AggregateRoot, type Name, type TextValue, type UniqueID } from "@repo/rdx-ddd";
|
||||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { PaymentMethodCannotBeDisabledError, PaymentMethodCannotBeUpdatedError } from "./errors";
|
import {
|
||||||
|
PaymentMethodCannotBeDefaultError,
|
||||||
|
PaymentMethodCannotBeDisabledError,
|
||||||
|
PaymentMethodCannotBeDeletedError,
|
||||||
|
PaymentMethodCannotBeUpdatedError,
|
||||||
|
} from "./errors";
|
||||||
|
import type { PaymentMethodSystemKey } from "./payment-method-system-key";
|
||||||
|
|
||||||
export interface IPaymentMethodCreateProps {
|
export interface IPaymentMethodCreateProps {
|
||||||
companyId: UniqueID;
|
companyId: UniqueID;
|
||||||
@ -9,6 +15,8 @@ export interface IPaymentMethodCreateProps {
|
|||||||
description: Maybe<TextValue>;
|
description: Maybe<TextValue>;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
systemKey: Maybe<PaymentMethodSystemKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentMethodPatchProps = Partial<{
|
export type PaymentMethodPatchProps = Partial<{
|
||||||
@ -57,6 +65,20 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
|
|||||||
return Result.fail(new Error("Payment method name is required"));
|
return Result.fail(new Error("Payment method name is required"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.isDefault && !props.isActive) {
|
||||||
|
return Result.fail(
|
||||||
|
new PaymentMethodCannotBeDefaultError("Default payment methods must remain active.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.isSystem && props.systemKey.isNone()) {
|
||||||
|
return Result.fail(new Error("System payment methods require a system key."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.isSystem && props.systemKey.isSome()) {
|
||||||
|
return Result.fail(new Error("Custom payment methods cannot define a system key."));
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,6 +110,14 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
|
|||||||
return this.props.isSystem;
|
return this.props.isSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get isDefault(): boolean {
|
||||||
|
return this.props.isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get systemKey(): Maybe<PaymentMethodSystemKey> {
|
||||||
|
return this.props.systemKey;
|
||||||
|
}
|
||||||
|
|
||||||
public update(patchProps: PaymentMethodPatchProps): Result<boolean, Error> {
|
public update(patchProps: PaymentMethodPatchProps): Result<boolean, Error> {
|
||||||
if (this.isSystem) {
|
if (this.isSystem) {
|
||||||
return Result.fail(
|
return Result.fail(
|
||||||
@ -133,6 +163,14 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isDefault) {
|
||||||
|
return Result.fail(
|
||||||
|
new PaymentMethodCannotBeDisabledError(
|
||||||
|
"Default payment methods cannot be disabled until another default is selected."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.isActive) {
|
if (!this.isActive) {
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@ -156,6 +194,50 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
|
|||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public markAsDefault(): Result<boolean, Error> {
|
||||||
|
if (!this.isActive) {
|
||||||
|
return Result.fail(
|
||||||
|
new PaymentMethodCannotBeDefaultError("Inactive payment methods cannot be marked default.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDefault) {
|
||||||
|
return Result.ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.isDefault = true;
|
||||||
|
|
||||||
|
return Result.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unmarkAsDefault(): Result<boolean, Error> {
|
||||||
|
if (!this.isDefault) {
|
||||||
|
return Result.ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.isDefault = false;
|
||||||
|
|
||||||
|
return Result.ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ensureCanBeDeleted(): Result<void, Error> {
|
||||||
|
if (this.isSystem) {
|
||||||
|
return Result.fail(
|
||||||
|
new PaymentMethodCannotBeDeletedError("System payment methods cannot be deleted.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDefault) {
|
||||||
|
return Result.fail(
|
||||||
|
new PaymentMethodCannotBeDeletedError(
|
||||||
|
"Default payment methods cannot be deleted until another default is selected."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
public toJSON() {
|
public toJSON() {
|
||||||
return {
|
return {
|
||||||
id: this.id.toPrimitive(),
|
id: this.id.toPrimitive(),
|
||||||
@ -167,6 +249,7 @@ export class PaymentMethod extends AggregateRoot<PaymentMethodInternalProps> {
|
|||||||
),
|
),
|
||||||
is_active: this.isActive,
|
is_active: this.isActive,
|
||||||
is_system: this.isSystem,
|
is_system: this.isSystem,
|
||||||
|
is_default: this.isDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,12 @@ import type { Sequelize } from "sequelize";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type IPaymentMethodPublicFinder,
|
type IPaymentMethodPublicFinder,
|
||||||
|
BootstrapSystemPaymentMethodsService,
|
||||||
|
BootstrapSystemPaymentMethodsUseCase,
|
||||||
PaymentMethodPublicFinder,
|
PaymentMethodPublicFinder,
|
||||||
PaymentMethodPublicModelMapper,
|
PaymentMethodPublicModelMapper,
|
||||||
|
SetDefaultPaymentMethodByIdUseCase,
|
||||||
|
SetDefaultPaymentMethodService,
|
||||||
buildPaymentMethodCreator,
|
buildPaymentMethodCreator,
|
||||||
buildPaymentMethodDeleter,
|
buildPaymentMethodDeleter,
|
||||||
buildPaymentMethodFinder,
|
buildPaymentMethodFinder,
|
||||||
@ -26,6 +30,7 @@ import {
|
|||||||
|
|
||||||
import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di";
|
import { buildPaymentMethodPersistenceMappers } from "./payment-method-persistence-mappers.di";
|
||||||
import { buildPaymentMethodRepository } from "./payment-method-repositories.di";
|
import { buildPaymentMethodRepository } from "./payment-method-repositories.di";
|
||||||
|
import { buildPaymentMethodReferenceUsageChecker } from "../payment-method-reference-usage-checker";
|
||||||
|
|
||||||
export type PaymentMethodsInternalDeps = {
|
export type PaymentMethodsInternalDeps = {
|
||||||
repository: IPaymentMethodRepository;
|
repository: IPaymentMethodRepository;
|
||||||
@ -37,6 +42,8 @@ export type PaymentMethodsInternalDeps = {
|
|||||||
deletePaymentMethodById: () => DeletePaymentMethodByIdUseCase;
|
deletePaymentMethodById: () => DeletePaymentMethodByIdUseCase;
|
||||||
disablePaymentMethodById: () => DisablePaymentMethodByIdUseCase;
|
disablePaymentMethodById: () => DisablePaymentMethodByIdUseCase;
|
||||||
enablePaymentMethodById: () => EnablePaymentMethodByIdUseCase;
|
enablePaymentMethodById: () => EnablePaymentMethodByIdUseCase;
|
||||||
|
setDefaultPaymentMethodById: () => SetDefaultPaymentMethodByIdUseCase;
|
||||||
|
bootstrapSystemPaymentMethods: () => BootstrapSystemPaymentMethodsUseCase;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,14 +57,20 @@ export const buildPaymentMethodsDependencies = (
|
|||||||
const persistenceMappers = buildPaymentMethodPersistenceMappers();
|
const persistenceMappers = buildPaymentMethodPersistenceMappers();
|
||||||
|
|
||||||
const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers });
|
const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers });
|
||||||
|
const usageChecker = buildPaymentMethodReferenceUsageChecker(database as Sequelize);
|
||||||
|
|
||||||
// Application helpers
|
// Application helpers
|
||||||
const inputMappers = buildPaymentMethodInputMappers();
|
const inputMappers = buildPaymentMethodInputMappers();
|
||||||
const finder = buildPaymentMethodFinder({ repository });
|
const finder = buildPaymentMethodFinder({ repository });
|
||||||
const creator = buildPaymentMethodCreator({ repository });
|
const creator = buildPaymentMethodCreator({ repository });
|
||||||
const updater = buildPaymentMethodUpdater({ repository });
|
const updater = buildPaymentMethodUpdater({ repository });
|
||||||
const deleter = buildPaymentMethodDeleter({ repository });
|
const deleter = buildPaymentMethodDeleter({ repository, usageChecker });
|
||||||
const statusChanger = buildPaymentMethodStatusChanger({ repository });
|
const statusChanger = buildPaymentMethodStatusChanger({ repository });
|
||||||
|
const setDefaultService = new SetDefaultPaymentMethodService(repository);
|
||||||
|
const bootstrapService = new BootstrapSystemPaymentMethodsService({
|
||||||
|
repository,
|
||||||
|
setDefaultService,
|
||||||
|
});
|
||||||
|
|
||||||
const snapshotBuilders = buildPaymentMethodSnapshotBuilders();
|
const snapshotBuilders = buildPaymentMethodSnapshotBuilders();
|
||||||
|
|
||||||
@ -110,6 +123,20 @@ export const buildPaymentMethodsDependencies = (
|
|||||||
fullSnapshotBuilder: snapshotBuilders.full,
|
fullSnapshotBuilder: snapshotBuilders.full,
|
||||||
transactionManager,
|
transactionManager,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setDefaultPaymentMethodById: () =>
|
||||||
|
new SetDefaultPaymentMethodByIdUseCase({
|
||||||
|
setDefaultService,
|
||||||
|
fullSnapshotBuilder: snapshotBuilders.full,
|
||||||
|
transactionManager,
|
||||||
|
}),
|
||||||
|
|
||||||
|
bootstrapSystemPaymentMethods: () =>
|
||||||
|
new BootstrapSystemPaymentMethodsUseCase({
|
||||||
|
bootstrapService,
|
||||||
|
fullSnapshotBuilder: snapshotBuilders.full,
|
||||||
|
transactionManager,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 "./enable-payment-method-by-id.controller";
|
||||||
export * from "./get-payment-method-by-id.controller";
|
export * from "./get-payment-method-by-id.controller";
|
||||||
export * from "./list-payment-methods.controller";
|
export * from "./list-payment-methods.controller";
|
||||||
|
export * from "./bootstrap-system-payment-methods.controller";
|
||||||
|
export * from "./set-default-payment-method-by-id.controller";
|
||||||
export * from "./update-payment-method-by-id.controller";
|
export * from "./update-payment-method-by-id.controller";
|
||||||
|
|||||||
@ -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 {
|
import {
|
||||||
type InvalidPaymentMethodIdError,
|
type InvalidPaymentMethodIdError,
|
||||||
type InvalidPaymentMethodNameError,
|
type InvalidPaymentMethodNameError,
|
||||||
|
type PaymentMethodCannotBeDefaultError,
|
||||||
type PaymentMethodCannotBeDeletedError,
|
type PaymentMethodCannotBeDeletedError,
|
||||||
type PaymentMethodCannotBeDisabledError,
|
type PaymentMethodCannotBeDisabledError,
|
||||||
type PaymentMethodCannotBeEnabledError,
|
type PaymentMethodCannotBeEnabledError,
|
||||||
type PaymentMethodCannotBeUpdatedError,
|
type PaymentMethodCannotBeUpdatedError,
|
||||||
|
type PaymentMethodInUseError,
|
||||||
type PaymentMethodNotFoundError,
|
type PaymentMethodNotFoundError,
|
||||||
isInvalidPaymentMethodIdError,
|
isInvalidPaymentMethodIdError,
|
||||||
isInvalidPaymentMethodNameError,
|
isInvalidPaymentMethodNameError,
|
||||||
|
isPaymentMethodCannotBeDefaultError,
|
||||||
isPaymentMethodCannotBeDeletedError,
|
isPaymentMethodCannotBeDeletedError,
|
||||||
isPaymentMethodCannotBeDisabledError,
|
isPaymentMethodCannotBeDisabledError,
|
||||||
isPaymentMethodCannotBeEnabledError,
|
isPaymentMethodCannotBeEnabledError,
|
||||||
isPaymentMethodCannotBeUpdatedError,
|
isPaymentMethodCannotBeUpdatedError,
|
||||||
|
isPaymentMethodInUseError,
|
||||||
isPaymentMethodNotFoundError,
|
isPaymentMethodNotFoundError,
|
||||||
} from "../../../domain/payment-methods";
|
} from "../../../domain/payment-methods";
|
||||||
|
|
||||||
@ -60,6 +64,16 @@ const paymentMethodCannotBeDeletedRule: ErrorToApiRule = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paymentMethodCannotBeDefaultRule: ErrorToApiRule = {
|
||||||
|
priority: 120,
|
||||||
|
matches: isPaymentMethodCannotBeDefaultError,
|
||||||
|
build: (error) =>
|
||||||
|
new ValidationApiError(
|
||||||
|
(error as PaymentMethodCannotBeDefaultError).message ||
|
||||||
|
"Payment method cannot be marked as default."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const paymentMethodCannotBeDisabledRule: ErrorToApiRule = {
|
const paymentMethodCannotBeDisabledRule: ErrorToApiRule = {
|
||||||
priority: 120,
|
priority: 120,
|
||||||
matches: isPaymentMethodCannotBeDisabledError,
|
matches: isPaymentMethodCannotBeDisabledError,
|
||||||
@ -87,11 +101,22 @@ const paymentMethodCannotBeUpdatedRule: ErrorToApiRule = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paymentMethodInUseRule: ErrorToApiRule = {
|
||||||
|
priority: 120,
|
||||||
|
matches: isPaymentMethodInUseError,
|
||||||
|
build: (error) =>
|
||||||
|
new ConflictApiError(
|
||||||
|
(error as PaymentMethodInUseError).message || "Payment method is currently in use."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const paymentMethodsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
|
export const paymentMethodsApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
|
||||||
.register(invalidPaymentMethodIdRule)
|
.register(invalidPaymentMethodIdRule)
|
||||||
.register(invalidPaymentMethodNameRule)
|
.register(invalidPaymentMethodNameRule)
|
||||||
.register(paymentMethodNotFoundRule)
|
.register(paymentMethodNotFoundRule)
|
||||||
|
.register(paymentMethodCannotBeDefaultRule)
|
||||||
.register(paymentMethodCannotBeDeletedRule)
|
.register(paymentMethodCannotBeDeletedRule)
|
||||||
.register(paymentMethodCannotBeDisabledRule)
|
.register(paymentMethodCannotBeDisabledRule)
|
||||||
.register(paymentMethodCannotBeEnabledRule)
|
.register(paymentMethodCannotBeEnabledRule)
|
||||||
.register(paymentMethodCannotBeUpdatedRule);
|
.register(paymentMethodCannotBeUpdatedRule)
|
||||||
|
.register(paymentMethodInUseRule);
|
||||||
|
|||||||
@ -3,22 +3,26 @@ import { requireIdentityTenant } from "@erp/identity/api";
|
|||||||
import { type NextFunction, type Request, type Response, Router } from "express";
|
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BootstrapSystemPaymentMethodsRequestSchema,
|
||||||
CreatePaymentMethodRequestSchema,
|
CreatePaymentMethodRequestSchema,
|
||||||
DeletePaymentMethodByIdRequestSchema,
|
DeletePaymentMethodByIdRequestSchema,
|
||||||
GetPaymentMethodByIdRequestSchema,
|
GetPaymentMethodByIdRequestSchema,
|
||||||
ListPaymentMethodsRequestSchema,
|
ListPaymentMethodsRequestSchema,
|
||||||
|
SetDefaultPaymentMethodByIdRequestSchema,
|
||||||
UpdatePaymentMethodByIdParamsRequestSchema,
|
UpdatePaymentMethodByIdParamsRequestSchema,
|
||||||
UpdatePaymentMethodByIdRequestSchema,
|
UpdatePaymentMethodByIdRequestSchema,
|
||||||
} from "../../../../common";
|
} from "../../../../common";
|
||||||
import type { CatalogsInternalDeps } from "../../di/catalogs.di";
|
import type { CatalogsInternalDeps } from "../../di/catalogs.di";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BootstrapSystemPaymentMethodsController,
|
||||||
CreatePaymentMethodController,
|
CreatePaymentMethodController,
|
||||||
DeletePaymentMethodByIdController,
|
DeletePaymentMethodByIdController,
|
||||||
DisablePaymentMethodByIdController,
|
DisablePaymentMethodByIdController,
|
||||||
EnablePaymentMethodByIdController,
|
EnablePaymentMethodByIdController,
|
||||||
GetPaymentMethodByIdController,
|
GetPaymentMethodByIdController,
|
||||||
ListPaymentMethodsController,
|
ListPaymentMethodsController,
|
||||||
|
SetDefaultPaymentMethodByIdController,
|
||||||
UpdatePaymentMethodByIdController,
|
UpdatePaymentMethodByIdController,
|
||||||
} from "./controllers";
|
} from "./controllers";
|
||||||
|
|
||||||
@ -43,6 +47,17 @@ export const paymentMethodsRouter = (params: StartParams) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/bootstrap-system",
|
||||||
|
validateRequest(BootstrapSystemPaymentMethodsRequestSchema, "body"),
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controller = new BootstrapSystemPaymentMethodsController(
|
||||||
|
deps.useCases.bootstrapSystemPaymentMethods()
|
||||||
|
);
|
||||||
|
return controller.execute(req, res, next);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
validateRequest(CreatePaymentMethodRequestSchema, "body"),
|
validateRequest(CreatePaymentMethodRequestSchema, "body"),
|
||||||
@ -84,6 +99,17 @@ export const paymentMethodsRouter = (params: StartParams) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/:payment_method_id/default",
|
||||||
|
validateRequest(SetDefaultPaymentMethodByIdRequestSchema, "params"),
|
||||||
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controller = new SetDefaultPaymentMethodByIdController(
|
||||||
|
deps.useCases.setDefaultPaymentMethodById()
|
||||||
|
);
|
||||||
|
return controller.execute(req, res, next);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.patch(
|
router.patch(
|
||||||
"/:payment_method_id/disable",
|
"/:payment_method_id/disable",
|
||||||
validateRequest(GetPaymentMethodByIdRequestSchema, "params"),
|
validateRequest(GetPaymentMethodByIdRequestSchema, "params"),
|
||||||
|
|||||||
@ -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";
|
} from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { PaymentMethod } from "../../../../../domain";
|
import { PaymentMethod, isPaymentMethodSystemKey } from "../../../../../domain";
|
||||||
import type { PaymentMethodCreationAttributes, PaymentMethodModel } from "../models";
|
import type { PaymentMethodCreationAttributes, PaymentMethodModel } from "../models";
|
||||||
|
|
||||||
export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
|
export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
|
||||||
@ -44,6 +44,15 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
|
|||||||
"description",
|
"description",
|
||||||
errors
|
errors
|
||||||
);
|
);
|
||||||
|
const systemKey = extractOrPushError(
|
||||||
|
maybeFromNullableResult(source.system_key, (value) =>
|
||||||
|
isPaymentMethodSystemKey(value)
|
||||||
|
? Result.ok(value)
|
||||||
|
: Result.fail(new Error(`Invalid payment method system key: ${value}`))
|
||||||
|
),
|
||||||
|
"system_key",
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
|
||||||
// Si hubo errores de mapeo, devolvemos colección de validación
|
// Si hubo errores de mapeo, devolvemos colección de validación
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
@ -59,6 +68,8 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
|
|||||||
description: description!,
|
description: description!,
|
||||||
isActive: source.is_active,
|
isActive: source.is_active,
|
||||||
isSystem: source.is_system,
|
isSystem: source.is_system,
|
||||||
|
isDefault: source.is_default,
|
||||||
|
systemKey: systemKey!,
|
||||||
},
|
},
|
||||||
idResult.data
|
idResult.data
|
||||||
);
|
);
|
||||||
@ -80,6 +91,11 @@ export class SequelizePaymentMethodDomainMapper extends SequelizeDomainMapper<
|
|||||||
description: maybeToNullable(source.description, (description) => description.toPrimitive()),
|
description: maybeToNullable(source.description, (description) => description.toPrimitive()),
|
||||||
is_active: source.isActive,
|
is_active: source.isActive,
|
||||||
is_system: source.isSystem,
|
is_system: source.isSystem,
|
||||||
|
is_default: source.isDefault,
|
||||||
|
system_key: source.systemKey.match(
|
||||||
|
(systemKey) => systemKey,
|
||||||
|
() => null
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper<
|
|||||||
const name = extractOrPushError(Name.create(raw.name), "name", errors);
|
const name = extractOrPushError(Name.create(raw.name), "name", errors);
|
||||||
const isActive = raw.is_active;
|
const isActive = raw.is_active;
|
||||||
const isSystem = raw.is_system;
|
const isSystem = raw.is_system;
|
||||||
|
const isDefault = raw.is_default;
|
||||||
|
|
||||||
// Si hubo errores de mapeo, devolvemos colección de validación
|
// Si hubo errores de mapeo, devolvemos colección de validación
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
@ -41,6 +42,7 @@ export class SequelizePaymentMethodSummaryMapper extends SequelizeQueryMapper<
|
|||||||
name: name!,
|
name: name!,
|
||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
isSystem: isSystem,
|
isSystem: isSystem,
|
||||||
|
isDefault: isDefault,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ export class PaymentMethodModel extends Model<
|
|||||||
declare description: CreationOptional<string | null>;
|
declare description: CreationOptional<string | null>;
|
||||||
declare is_active: boolean;
|
declare is_active: boolean;
|
||||||
declare is_system: boolean;
|
declare is_system: boolean;
|
||||||
|
declare is_default: boolean;
|
||||||
|
declare system_key: CreationOptional<string | null>;
|
||||||
|
|
||||||
static associate(_database: Sequelize) {}
|
static associate(_database: Sequelize) {}
|
||||||
|
|
||||||
@ -58,6 +60,16 @@ export default (database: Sequelize) => {
|
|||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
is_default: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
system_key: {
|
||||||
|
type: DataTypes.STRING(40),
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize: database,
|
sequelize: database,
|
||||||
@ -79,6 +91,13 @@ export default (database: Sequelize) => {
|
|||||||
defaultScope: {},
|
defaultScope: {},
|
||||||
|
|
||||||
scopes: {},
|
scopes: {},
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: "uq_payment_methods_company_system_key",
|
||||||
|
fields: ["company_id", "system_key"],
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { Sequelize, Transaction } from "sequelize";
|
|||||||
|
|
||||||
import type { IPaymentMethodRepository } from "../../../../../application";
|
import type { IPaymentMethodRepository } from "../../../../../application";
|
||||||
import type { PaymentMethodSummary } from "../../../../../application/payment-methods/models";
|
import type { PaymentMethodSummary } from "../../../../../application/payment-methods/models";
|
||||||
import type { PaymentMethod } from "../../../../../domain";
|
import type { PaymentMethod, PaymentMethodSystemKey } from "../../../../../domain";
|
||||||
import type { SequelizePaymentMethodSummaryMapper } from "../mappers";
|
import type { SequelizePaymentMethodSummaryMapper } from "../mappers";
|
||||||
import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper";
|
import type { SequelizePaymentMethodDomainMapper } from "../mappers/sequelize-payment-method-domain.mapper";
|
||||||
import { PaymentMethodModel } from "../models";
|
import { PaymentMethodModel } from "../models";
|
||||||
@ -174,6 +174,138 @@ export class SequelizePaymentMethodRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findDefaultInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
transaction?: Transaction
|
||||||
|
): Promise<Result<Maybe<PaymentMethod>, Error>> {
|
||||||
|
try {
|
||||||
|
const row = await PaymentMethodModel.findOne({
|
||||||
|
where: {
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return Result.ok(Maybe.none<PaymentMethod>());
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedResult = this.domainMapper.mapToDomain(row);
|
||||||
|
if (mappedResult.isFailure) {
|
||||||
|
return Result.fail(mappedResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(Maybe.some(mappedResult.data));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findBySystemKeyInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
systemKey: PaymentMethodSystemKey,
|
||||||
|
transaction?: Transaction,
|
||||||
|
options?: { includeDeleted?: boolean }
|
||||||
|
): Promise<Result<Maybe<PaymentMethod>, Error>> {
|
||||||
|
try {
|
||||||
|
const row = await PaymentMethodModel.findOne({
|
||||||
|
where: {
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
system_key: systemKey,
|
||||||
|
},
|
||||||
|
paranoid: !(options?.includeDeleted ?? false),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return Result.ok(Maybe.none<PaymentMethod>());
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedResult = this.domainMapper.mapToDomain(row);
|
||||||
|
if (mappedResult.isFailure) {
|
||||||
|
return Result.fail(mappedResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.ok(Maybe.some(mappedResult.data));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restoreByIdInCompany(
|
||||||
|
companyId: UniqueID,
|
||||||
|
id: UniqueID,
|
||||||
|
transaction?: Transaction
|
||||||
|
): Promise<Result<boolean, Error>> {
|
||||||
|
try {
|
||||||
|
const restored = await PaymentMethodModel.restore({
|
||||||
|
where: {
|
||||||
|
id: id.toString(),
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Result.ok(restored > 0);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncSystemPaymentMethodState(params: {
|
||||||
|
companyId: UniqueID;
|
||||||
|
id: UniqueID;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
systemKey: PaymentMethodSystemKey;
|
||||||
|
transaction?: Transaction;
|
||||||
|
}): Promise<Result<void, Error>> {
|
||||||
|
try {
|
||||||
|
await PaymentMethodModel.update(
|
||||||
|
{
|
||||||
|
name: params.name,
|
||||||
|
is_active: true,
|
||||||
|
is_system: true,
|
||||||
|
is_default: params.isDefault,
|
||||||
|
system_key: params.systemKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
id: params.id.toString(),
|
||||||
|
company_id: params.companyId.toString(),
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
transaction: params.transaction,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async lockByCompanyId(
|
||||||
|
companyId: UniqueID,
|
||||||
|
transaction?: Transaction
|
||||||
|
): Promise<Result<void, Error>> {
|
||||||
|
try {
|
||||||
|
await PaymentMethodModel.findAll({
|
||||||
|
attributes: ["id"],
|
||||||
|
where: {
|
||||||
|
company_id: companyId.toString(),
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
lock: transaction ? transaction.LOCK.UPDATE : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Result.ok();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.fail(translateSequelizeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
|
* Recupera múltiples customers dentro de una empresa según un criterio dinámico (búsqueda, paginación, etc.).
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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 "./create-payment-method.request.dto";
|
||||||
|
export * from "./bootstrap-system-payment-methods.request.dto";
|
||||||
export * from "./delete-payment-method-by-id.request.dto";
|
export * from "./delete-payment-method-by-id.request.dto";
|
||||||
export * from "./get-payment-method-by-id.request.dto";
|
export * from "./get-payment-method-by-id.request.dto";
|
||||||
export * from "./list-payment-methods.request.dto";
|
export * from "./list-payment-methods.request.dto";
|
||||||
|
export * from "./set-default-payment-method-by-id.request.dto";
|
||||||
export * from "./update-payment-method-by-id.request.dto";
|
export * from "./update-payment-method-by-id.request.dto";
|
||||||
|
|||||||
@ -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 "./create-payment-method.response.dto";
|
||||||
|
export * from "./bootstrap-system-payment-methods.response.dto";
|
||||||
export * from "./disable-payment-method-by-id.response.dto";
|
export * from "./disable-payment-method-by-id.response.dto";
|
||||||
export * from "./get-payment-method-by-id.response.dto";
|
export * from "./get-payment-method-by-id.response.dto";
|
||||||
export * from "./list-payment-methods.response.dto";
|
export * from "./list-payment-methods.response.dto";
|
||||||
|
export * from "./set-default-payment-method-by-id.response.dto";
|
||||||
export * from "./update-payment-method-by-id.response.dto";
|
export * from "./update-payment-method-by-id.response.dto";
|
||||||
|
|||||||
@ -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(),
|
description: z.string().nullable(),
|
||||||
is_active: z.boolean(),
|
is_active: z.boolean(),
|
||||||
is_system: z.boolean(),
|
is_system: z.boolean(),
|
||||||
|
is_default: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PaymentMethodDetailDTO = z.infer<typeof PaymentMethodDetailSchema>;
|
export type PaymentMethodDetailDTO = z.infer<typeof PaymentMethodDetailSchema>;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const PaymentMethodSummarySchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
is_active: z.boolean(),
|
is_active: z.boolean(),
|
||||||
is_system: z.boolean(),
|
is_system: z.boolean(),
|
||||||
|
is_default: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PaymentMethodSummaryDTO = z.infer<typeof PaymentMethodSummarySchema>;
|
export type PaymentMethodSummaryDTO = z.infer<typeof PaymentMethodSummarySchema>;
|
||||||
|
|||||||
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";
|
export const MODULE_NAME = "Catalogs";
|
||||||
const MODULE_VERSION = "1.0.0";
|
const MODULE_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
ensureModuleTranslations();
|
||||||
|
|
||||||
export const CatalogsModuleManifest: IModuleClient = {
|
export const CatalogsModuleManifest: IModuleClient = {
|
||||||
name: MODULE_NAME,
|
name: MODULE_NAME,
|
||||||
version: MODULE_VERSION,
|
version: MODULE_VERSION,
|
||||||
dependencies: ["auth", "Core"],
|
dependencies: ["auth", "Core"],
|
||||||
protected: true,
|
protected: true,
|
||||||
layout: "app-sidebar",
|
layout: "app-sidebar",
|
||||||
|
settings: {
|
||||||
routes: (params: ModuleClientParams) => {
|
entries: [
|
||||||
return [];
|
{
|
||||||
|
id: "payment-methods",
|
||||||
|
sectionId: "general",
|
||||||
|
labelKey: "settings.paymentMethods.title",
|
||||||
|
path: "/settings/general/payment-methods",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
routes: (params) => CatalogsRoutes(params),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CatalogsModuleManifest;
|
export default CatalogsModuleManifest;
|
||||||
|
|||||||
@ -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 = {
|
export const ListPaymentMethodsAdapter = {
|
||||||
fromDto(dto: ListPaymentMethodsResult, context?: unknown): PaymentMethodList {
|
fromDto(dto: ListPaymentMethodsResult): PaymentMethodList {
|
||||||
return {
|
return {
|
||||||
page: dto.page,
|
page: dto.page,
|
||||||
perPage: dto.per_page,
|
perPage: dto.per_page,
|
||||||
totalPages: dto.total_pages,
|
totalPages: dto.total_pages,
|
||||||
totalItems: dto.total_items,
|
totalItems: dto.total_items,
|
||||||
items: dto.items.map((rowDto) => PaymentMehodListRowAdapter.fromDto(rowDto, context)),
|
items: dto.items.map((rowDto) => PaymentMehodListRowAdapter.fromDto(rowDto)),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -39,7 +39,7 @@ export const ListPaymentMethodsAdapter = {
|
|||||||
type ListPaymentMethodsItemOutput = ListPaymentMethodsResponseDTO["items"][number];
|
type ListPaymentMethodsItemOutput = ListPaymentMethodsResponseDTO["items"][number];
|
||||||
|
|
||||||
const PaymentMehodListRowAdapter = {
|
const PaymentMehodListRowAdapter = {
|
||||||
fromDto(dto: ListPaymentMethodsItemOutput, context?: unknown): PaymentMethodListRow {
|
fromDto(dto: ListPaymentMethodsItemOutput): PaymentMethodListRow {
|
||||||
return {
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
companyId: dto.company_id,
|
companyId: dto.company_id,
|
||||||
@ -48,6 +48,7 @@ const PaymentMehodListRowAdapter = {
|
|||||||
|
|
||||||
isSystem: dto.is_system,
|
isSystem: dto.is_system,
|
||||||
isActive: dto.is_active,
|
isActive: dto.is_active,
|
||||||
|
isDefault: dto.is_default,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 "./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;
|
isSystem: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,4 +7,5 @@ export interface PaymentMethod {
|
|||||||
|
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,8 @@
|
|||||||
export * from "./use-payment-method-list-query";
|
export * from "./use-payment-method-list-query";
|
||||||
|
export * from "./use-payment-method-detail-query";
|
||||||
|
export * from "./use-payment-method-create-mutation";
|
||||||
|
export * from "./use-payment-method-update-mutation";
|
||||||
|
export * from "./use-payment-method-delete-mutation";
|
||||||
|
export * from "./use-payment-method-enable-mutation";
|
||||||
|
export * from "./use-payment-method-disable-mutation";
|
||||||
|
export * from "./use-payment-method-set-default-mutation";
|
||||||
|
|||||||
@ -1,21 +1,15 @@
|
|||||||
|
import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
|
||||||
import type { QueryKey } from "@tanstack/react-query";
|
import type { QueryKey } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { ListPaymentMethodsRequestDTO } from "../../../../common";
|
import type { ListPaymentMethodsRequestDTO } from "../../../../common";
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefijo base para listados
|
|
||||||
*/
|
|
||||||
export const LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX = ["payment-methods"] as const;
|
export const LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX = ["payment-methods"] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Query key para listado de payment methods
|
|
||||||
*/
|
|
||||||
export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequestDTO): QueryKey =>
|
export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequestDTO): QueryKey =>
|
||||||
[
|
[
|
||||||
...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
|
...LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
|
||||||
{
|
{
|
||||||
pageNumber: criteria?.pageNumber ?? 1,
|
pageNumber: criteria?.pageNumber ?? INITIAL_PAGE_INDEX,
|
||||||
pageSize: criteria?.pageSize ?? 5,
|
pageSize: criteria?.pageSize ?? INITIAL_PAGE_SIZE,
|
||||||
q: criteria?.q ?? "",
|
q: criteria?.q ?? "",
|
||||||
filters: criteria?.filters ?? [],
|
filters: criteria?.filters ?? [],
|
||||||
orderBy: criteria?.orderBy ?? "",
|
orderBy: criteria?.orderBy ?? "",
|
||||||
@ -23,22 +17,21 @@ export const LIST_PAYMENT_METHODS_QUERY_KEY = (criteria?: ListPaymentMethodsRequ
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Query key para detalle de payment method
|
|
||||||
*/
|
|
||||||
export const PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX = ["payment-methods:detail"] as const;
|
export const PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX = ["payment-methods:detail"] as const;
|
||||||
export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [
|
export const PAYMENT_METHOD_QUERY_KEY = (paymentMethodId?: string): QueryKey => [
|
||||||
...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX,
|
...PAYMENT_METHODS_DETAIL_QUERY_KEY_PREFIX,
|
||||||
{ paymentMethodId },
|
{ paymentMethodId },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Keys para mutaciones
|
|
||||||
*/
|
|
||||||
export const CREATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:create"] as const;
|
export const CREATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:create"] as const;
|
||||||
export const UPDATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:update"] as const;
|
export const UPDATE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:update"] as const;
|
||||||
export const DELETE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:delete"] as const;
|
export const DELETE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:delete"] as const;
|
||||||
|
export const ENABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:enable"] as const;
|
||||||
|
export const DISABLE_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:disable"] as const;
|
||||||
|
export const SET_DEFAULT_PAYMENT_METHOD_MUTATION_KEY = ["payment-methods:set-default"] as const;
|
||||||
|
|
||||||
/**
|
export const paymentMethodKeys = {
|
||||||
* Operaciones de dominio
|
all: LIST_PAYMENT_METHODS_QUERY_KEY_PREFIX,
|
||||||
*/
|
list: (criteria?: ListPaymentMethodsRequestDTO) => LIST_PAYMENT_METHODS_QUERY_KEY(criteria),
|
||||||
|
detail: (paymentMethodId?: string) => PAYMENT_METHOD_QUERY_KEY(paymentMethodId),
|
||||||
|
};
|
||||||
|
|||||||
@ -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 "./entities";
|
||||||
export * from "./hooks";
|
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