Módulo Identity

This commit is contained in:
David Arranz 2026-07-01 09:51:22 +02:00
parent cc90f64cb4
commit 7a161717c2
229 changed files with 4893 additions and 539 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges-server",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
@ -39,6 +39,7 @@
"@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/identity": "workspace:*",
"@erp/factuges": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",

View File

@ -2,6 +2,7 @@ import catalogsAPIModule from "@erp/catalogs/api";
import customerInvoicesAPIModule from "@erp/customer-invoices/api";
import customersAPIModule from "@erp/customers/api";
import factuGESAPIModule from "@erp/factuges/api";
import identityAPIModule from "@erp/identity/api";
//import suppliersAPIModule from "@erp/suppliers/api";
@ -13,7 +14,7 @@ import { registerModule } from "./lib";
// las rutas, los modelos y los servicios
// públicos de ese módulo.
export const registerModules = () => {
//registerModule(authAPIModule);
registerModule(identityAPIModule);
registerModule(catalogsAPIModule);
registerModule(customersAPIModule);
registerModule(customerInvoicesAPIModule);

View File

@ -1,7 +1,7 @@
{
"name": "@erp/factuges-web",
"private": true,
"version": "0.7.6",
"version": "0.7.8",
"type": "module",
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/catalogs",
"description": "Catalogs module",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/core",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,2 +1,3 @@
export * from "./dto.d";
export * from "./module-metadata.d";
export * from "./permissions";

View File

@ -0,0 +1,7 @@
export type PermissionCode = string;
export type PermissionDefinition = {
code: PermissionCode;
module: string;
description: string;
};

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customer-invoices",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,9 +1,6 @@
export * from "../entities/invoice-tax-regime.entity";
export * from "./invoice-address-type.vo";
export * from "./invoice-amount.vo";
export * from "./invoice-number.vo";
export * from "./invoice-recipient/invoice-recipient.vo";
export * from "./invoice-serie.vo";
export * from "./invoice-status.vo";
export * from "./item-amount.vo";

View File

@ -1 +0,0 @@
export * from "./invoice-recipient.vo";

View File

@ -15,7 +15,6 @@ import type {
InvoiceAmount,
InvoiceNumber,
InvoicePaymentMethod,
InvoiceRecipient,
InvoiceSerie,
InvoiceStatus,
} from "../../common";
@ -27,6 +26,7 @@ import {
type VerifactuRecord,
} from "../entities";
import { IssuedInvoiceItemMismatch } from "../errors";
import type { IssuedInvoiceRecipient } from "../value-objects";
export interface IIssuedInvoiceCreateProps {
companyId: UniqueID;
@ -41,7 +41,7 @@ export interface IIssuedInvoiceCreateProps {
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
recipient: InvoiceRecipient;
recipient: IssuedInvoiceRecipient;
reference: Maybe<string>;
description: string;
@ -214,7 +214,7 @@ export class IssuedInvoice
return this.props.notes;
}
public get recipient(): InvoiceRecipient {
public get recipient(): IssuedInvoiceRecipient {
return this.props.recipient;
}

View File

@ -1,2 +1,3 @@
export * from "./issued-invoice-recipient.vo";
export * from "./issued-invoice-tax-group.vo";
export * from "./verifactu-status.vo";

View File

@ -0,0 +1,103 @@
import {
type City,
type Country,
type Name,
type PostalCode,
type Province,
type Street,
type TINNumber,
ValueObject,
maybeToNullable,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export type IssuedInvoiceRecipientProps = {
name: Name;
tin: TINNumber;
street: Maybe<Street>;
street2: Maybe<Street>;
city: Maybe<City>;
postalCode: Maybe<PostalCode>;
province: Maybe<Province>;
country: Maybe<Country>;
};
export class IssuedInvoiceRecipient extends ValueObject<IssuedInvoiceRecipientProps> {
protected static validate(values: IssuedInvoiceRecipientProps) {
return Result.ok(values);
}
static create(values: IssuedInvoiceRecipientProps): Result<IssuedInvoiceRecipient, Error> {
const valueIsValid = IssuedInvoiceRecipient.validate(values);
if (valueIsValid.isFailure) {
return Result.fail(valueIsValid.error);
}
return Result.ok(new IssuedInvoiceRecipient(values));
}
public update(
partial: Partial<IssuedInvoiceRecipientProps>
): Result<IssuedInvoiceRecipient, Error> {
const updatedProps = {
...this.props,
...partial,
} as IssuedInvoiceRecipientProps;
return IssuedInvoiceRecipient.create(updatedProps);
}
public get name(): Name {
return this.props.name;
}
public get tin(): TINNumber {
return this.props.tin;
}
get street(): Maybe<Street> {
return this.props.street;
}
get street2(): Maybe<Street> {
return this.props.street2;
}
get city(): Maybe<City> {
return this.props.city;
}
get postalCode(): Maybe<PostalCode> {
return this.props.postalCode;
}
get province(): Maybe<Province> {
return this.props.province;
}
get country(): Maybe<Country> {
return this.props.country;
}
getProps(): IssuedInvoiceRecipientProps {
return this.props;
}
toPrimitive() {
return this.getProps();
}
toObjectString() {
return {
tin: this.tin.toString(),
name: this.name.toString(),
street: maybeToNullable(this.street, (value) => value.toString()),
street2: maybeToNullable(this.street2, (value) => value.toString()),
city: maybeToNullable(this.city, (value) => value.toString()),
postal_code: maybeToNullable(this.postalCode, (value) => value.toString()),
province: maybeToNullable(this.province, (value) => value.toString()),
country: maybeToNullable(this.country, (value) => value.toString()),
};
}
}

View File

@ -15,7 +15,6 @@ import {
INVOICE_STATUS,
InvoiceAmount,
type InvoiceNumber,
type InvoiceRecipient,
type InvoiceSerie,
InvoiceStatus,
type ItemAmount,
@ -30,7 +29,7 @@ import {
import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors";
import type { IProformaTaxTotals, ProformaCalculationContext } from "../services";
import { canManuallyTransitionProformaStatus } from "../services";
import { ProformaItemTaxes } from "../value-objects";
import { ProformaItemTaxes, type ProformaRecipient } from "../value-objects";
export interface IProformaCreateProps {
companyId: UniqueID;
@ -43,7 +42,7 @@ export interface IProformaCreateProps {
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
recipient: Maybe<InvoiceRecipient>;
recipient: Maybe<ProformaRecipient>;
reference: Maybe<string>;
description: Maybe<string>;
@ -93,7 +92,7 @@ export interface IProforma {
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
recipient: Maybe<InvoiceRecipient>;
recipient: Maybe<ProformaRecipient>;
reference: Maybe<string>;
description: Maybe<string>;
@ -258,7 +257,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.notes;
}
public get recipient(): Maybe<InvoiceRecipient> {
public get recipient(): Maybe<ProformaRecipient> {
return this.props.recipient;
}

View File

@ -1 +1,2 @@
export * from "./proforma-item-taxes.vo";
export * from "./proforma-recipient.vo";

View File

@ -11,7 +11,7 @@ import {
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
export type InvoiceRecipientProps = {
export type ProformaRecipientProps = {
name: Name;
tin: Maybe<TINNumber>;
street: Maybe<Street>;
@ -22,28 +22,28 @@ export type InvoiceRecipientProps = {
country: Maybe<Country>;
};
export class InvoiceRecipient extends ValueObject<InvoiceRecipientProps> {
protected static validate(values: InvoiceRecipientProps) {
export class ProformaRecipient extends ValueObject<ProformaRecipientProps> {
protected static validate(values: ProformaRecipientProps) {
return Result.ok(values);
}
static create(values: InvoiceRecipientProps): Result<InvoiceRecipient, Error> {
const valueIsValid = InvoiceRecipient.validate(values);
static create(values: ProformaRecipientProps): Result<ProformaRecipient, Error> {
const valueIsValid = ProformaRecipient.validate(values);
if (valueIsValid.isFailure) {
return Result.fail(valueIsValid.error);
}
return Result.ok(new InvoiceRecipient(values));
return Result.ok(new ProformaRecipient(values));
}
public update(partial: Partial<InvoiceRecipientProps>): Result<InvoiceRecipient, Error> {
public update(partial: Partial<ProformaRecipientProps>): Result<ProformaRecipient, Error> {
const updatedProps = {
...this.props,
...partial,
} as InvoiceRecipientProps;
} as ProformaRecipientProps;
return InvoiceRecipient.create(updatedProps);
return ProformaRecipient.create(updatedProps);
}
public get name(): Name {
@ -78,7 +78,7 @@ export class InvoiceRecipient extends ValueObject<InvoiceRecipientProps> {
return this.props.country;
}
getProps(): InvoiceRecipientProps {
getProps(): ProformaRecipientProps {
return this.props;
}

View File

@ -15,14 +15,14 @@ import {
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { type IIssuedInvoiceCreateProps, InvoiceRecipient } from "../../../../../../domain";
import { type IIssuedInvoiceCreateProps, IssuedInvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeIssuedInvoiceRecipientDomainMapper {
public mapToDomain(
source: CustomerInvoiceModel,
params?: MapperParamsType
): Result<InvoiceRecipient, Error> {
): Result<IssuedInvoiceRecipient, Error> {
/**
* - Issued invoice -> snapshot de los datos (campos customer_*)
*/
@ -45,11 +45,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
const customerName = extractOrPushError(Name.create(_name!), "customer_name", errors);
//const customerTin = extractOrPushError(TINNumber.create(_tin!), "customer_tin", errors);
const customerTin = extractOrPushError(
maybeFromNullableResult(_tin, (value) => TINNumber.create(value)),
"customer_tin",
errors
);
const customerTin = extractOrPushError(TINNumber.create(_tin), "customer_tin", errors);
const customerStreet = extractOrPushError(
maybeFromNullableResult(_street, (value) => Street.create(value)),
@ -87,7 +83,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
errors
);
const createResult = InvoiceRecipient.create({
const createResult = IssuedInvoiceRecipient.create({
name: customerName!,
tin: customerTin!,
street: customerStreet!,
@ -118,7 +114,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
* - Si la factura no es proforma (`isProforma === false`), debe existir `recipient`.
* En caso contrario, se agrega un error de validación.
*/
mapToPersistence(source: InvoiceRecipient, params?: MapperParamsType) {
mapToPersistence(source: IssuedInvoiceRecipient, params?: MapperParamsType) {
/*const { errors, parent } = params as {
parent: IssuedInvoice;
errors: ValidationErrorDetail[];
@ -127,7 +123,7 @@ export class SequelizeIssuedInvoiceRecipientDomainMapper {
const recipient = source;
return {
customer_tin: maybeToNullable(recipient.tin, (value) => value.toString()),
customer_tin: recipient.tin.toString(),
customer_name: recipient.name.toPrimitive(),
customer_street: maybeToNullable(recipient.street, (v) => v.toPrimitive()),
customer_street2: maybeToNullable(recipient.street2, (v) => v.toPrimitive()),

View File

@ -15,17 +15,17 @@ import {
import { Result } from "@repo/rdx-utils";
import type { IssuedInvoiceSummary } from "../../../../../../application";
import { InvoiceRecipient } from "../../../../../../domain";
import { IssuedInvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMapper<
CustomerInvoiceModel,
InvoiceRecipient
IssuedInvoiceRecipient
> {
public mapToReadModel(
raw: CustomerInvoiceModel,
params?: MapperParamsType
): Result<InvoiceRecipient, Error> {
): Result<IssuedInvoiceRecipient, Error> {
/**
* - Issued invoice => snapshot de los datos (campos customer_*)
*/
@ -94,7 +94,7 @@ export class SequelizeIssuedInvoiceRecipientListMapper extends SequelizeQueryMap
errors
);
const createResult = InvoiceRecipient.create({
const createResult = IssuedInvoiceRecipient.create({
name: customerName!,
tin: customerTin!,
street: customerStreet!,

View File

@ -14,14 +14,14 @@ import {
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { type IProformaCreateProps, InvoiceRecipient } from "../../../../../../domain";
import { type IProformaCreateProps, ProformaRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeProformaRecipientDomainMapper {
public mapToDomain(
source: CustomerInvoiceModel,
params?: MapperParamsType
): Result<Maybe<InvoiceRecipient>, Error> {
): Result<Maybe<ProformaRecipient>, Error> {
/**
* En proforma -> datos de "current_customer"
*/
@ -85,7 +85,7 @@ export class SequelizeProformaRecipientDomainMapper {
errors
);
const createResult = InvoiceRecipient.create({
const createResult = ProformaRecipient.create({
name: customerName!,
tin: customerTin!,
street: customerStreet!,
@ -110,7 +110,7 @@ export class SequelizeProformaRecipientDomainMapper {
/**
* Para una proforma, el recipient no se persiste (todos los campos son null).
*/
mapToPersistence(source: Maybe<InvoiceRecipient>, params?: MapperParamsType) {
mapToPersistence(source: Maybe<ProformaRecipient>, params?: MapperParamsType) {
return {
customer_tin: null,
customer_name: null,

View File

@ -15,17 +15,17 @@ import {
import { Result } from "@repo/rdx-utils";
import type { ProformaSummary } from "../../../../../../application";
import { InvoiceRecipient } from "../../../../../../domain";
import { ProformaRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper<
CustomerInvoiceModel,
InvoiceRecipient
ProformaRecipient
> {
public mapToReadModel(
raw: CustomerInvoiceModel,
params?: MapperParamsType
): Result<InvoiceRecipient, Error> {
): Result<ProformaRecipient, Error> {
/**
* En proforma -> datos de "current_customer"
*/
@ -43,6 +43,7 @@ export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper
message: "Current customer not included in query (SequelizeInvoiceRecipientListMapper)",
});
}
const _name = raw.current_customer!.name;
const _tin = raw.current_customer!.tin;
const _street = raw.current_customer!.street;
@ -98,7 +99,7 @@ export class SequelizeInvoiceRecipientSummaryMapper extends SequelizeQueryMapper
errors
);
const createResult = InvoiceRecipient.create({
const createResult = ProformaRecipient.create({
name: customerName!,
tin: customerTin!,
street: customerStreet!,

View File

@ -1,5 +1,5 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { AppContent, AppHeader, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
import { AppContent, BackHistoryButton, LogoVerifactu } from "@repo/rdx-ui/components";
import {
Alert,
AlertDescription,
@ -57,25 +57,23 @@ export const ListIssuedInvoicesPage = () => {
}
return (
<section>
<AppHeader>
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<Button
aria-label={t("pages.issued_invoices.create.title")}
className="hidden"
onClick={() => navigate("/issued-invoices/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.issued_invoices.create.title")}
</Button>
}
title={t("pages.issued_invoices.list.title")}
/>
</AppHeader>
<div className="p-6 space-y-6">
<PageHeader
description={t("pages.issued_invoices.list.description")}
rightSlot={
<Button
aria-label={t("pages.issued_invoices.create.title")}
className="hidden"
onClick={() => navigate("/issued-invoices/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.issued_invoices.create.title")}
</Button>
}
title={t("pages.issued_invoices.list.title")}
/>
<AppContent className="space-y-6">
<div className="mx-auto w-full space-y-4">
<Alert className="bg-green-50 text-green-800 flex items-center justify-between gap-6">
<div>
<AlertTitle className="font-semibold text-green-800">¡Atención!</AlertTitle>
@ -154,7 +152,7 @@ export const ListIssuedInvoicesPage = () => {
</div>
</>
)}
</AppContent>
</section>
</div>
</div>
);
};

View File

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

View File

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

View File

@ -1,118 +0,0 @@
import type { TaxCatalogProvider } from "@erp/core";
import type { ModuleTFunction } from "@repo/rdx-ui/locales/i18n.js";
import {
type PropsWithChildren,
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { useTranslation } from "../../../../i18n";
export type ProformaContextValue = {
company_id: string;
proforma_id: string;
status: string;
currency_code: string;
language_code: string;
is_proforma: boolean;
readOnly: boolean;
taxCatalog: TaxCatalogProvider;
t: ModuleTFunction;
changeLanguage: (lang: string) => void;
changeCurrency: (currency: string) => void;
changeIsProforma: (value: boolean) => void;
};
const ProformaContext = createContext<ProformaContextValue | null>(null);
export interface ProformaProviderParams {
taxCatalog: TaxCatalogProvider;
proforma_id: string;
company_id: string;
status: string; // default "draft"
language_code?: string; // default "es"
currency_code?: string; // default "EUR"
is_proforma?: boolean; // default 'true'
readOnly?: boolean; // default 'false'
children: React.ReactNode;
}
export const ProformaProvider = ({
taxCatalog: initialTaxCatalog,
proforma_id,
company_id,
status: initialStatus = "draft",
language_code: initialLang = "es",
currency_code: initialCurrency = "EUR",
readOnly: initialReadOnly = false,
is_proforma: initialProforma = true,
children,
}: PropsWithChildren<ProformaProviderParams>) => {
const { t } = useTranslation();
// Estado interno local para campos dinámicos
const [language_code, setLanguage] = useState(initialLang);
const [currency_code, setCurrency] = useState(initialCurrency);
const [is_proforma, setIsProforma] = useState(initialProforma);
const [readOnly, setReadOnly] = useState(initialReadOnly);
const [status] = useState(initialStatus);
const [taxCatalog] = useState(initialTaxCatalog);
// Callbacks memoizados
const setLanguageMemo = useCallback((language_code: string) => setLanguage(language_code), []);
const setCurrencyMemo = useCallback((currency_code: string) => setCurrency(currency_code), []);
const setIsProformaMemo = useCallback((is_proforma: boolean) => setIsProforma(is_proforma), []);
const setReadOnlyMemo = useCallback((readOnly: boolean) => setReadOnly(readOnly), []);
const value = useMemo<ProformaContextValue>(() => {
return {
t,
proforma_id,
company_id,
status,
language_code,
currency_code,
is_proforma,
taxCatalog,
readOnly,
changeLanguage: setLanguageMemo,
changeCurrency: setCurrencyMemo,
changeIsProforma: setIsProformaMemo,
setReadOnly: setReadOnlyMemo,
};
}, [
t,
readOnly,
company_id,
proforma_id,
status,
language_code,
currency_code,
is_proforma,
taxCatalog,
setLanguageMemo,
setCurrencyMemo,
setIsProformaMemo,
setReadOnlyMemo,
]);
return <ProformaContext.Provider value={value}>{children}</ProformaContext.Provider>;
};
export function useProformaContext(): ProformaContextValue {
const context = useContext(ProformaContext);
if (!context) {
throw new Error("useProformaContext must be used within <ProformaProvider>");
}
return context;
}

View File

@ -1 +0,0 @@
export * from "./proforma-update-page";

View File

@ -1,2 +0,0 @@
export * from "./proforma-basic-info-fields";
export * from "./proforma-totals";

View File

@ -1,75 +0,0 @@
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import type { ProformaFormData } from "../../../../types";
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<ProformaFormData>();
return (
<FieldSet {...props}>
<FieldLegend className="hidden text-foreground" variant="label">
{t("form_groups.basic_info.title")}
</FieldLegend>
<FieldDescription className="hidden">
{t("form_groups.basic_info.description")}
</FieldDescription>
<FieldGroup className="flex flex-row flex-wrap gap-6 xl:flex-nowrap">
<DatePickerInputField
className="min-w-44 flex-1 sm:max-w-44"
control={control}
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
numberOfMonths={2}
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<DatePickerInputField
className="min-w-44 flex-1 sm:max-w-44"
control={control}
description={t("form_fields.operation_date.description")}
label={t("form_fields.operation_date.label")}
name="operation_date"
numberOfMonths={2}
placeholder={t("form_fields.operation_date.placeholder")}
/>
<TextField
className="min-w-16 flex-1 sm:max-w-16"
control={control}
description={t("form_fields.series.description")}
label={t("form_fields.series.label")}
name="series"
placeholder={t("form_fields.series.placeholder")}
/>
<TextField
className="min-w-32 flex-1 sm:max-w-44"
control={control}
description={t("form_fields.reference.description")}
label={t("form_fields.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.reference.placeholder")}
/>
<TextField
className="min-w-32 flex-1 xs:max-w-full"
control={control}
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.description.placeholder")}
/>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,153 +0,0 @@
import {
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
Separator,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ReceiptIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../../../../i18n";
import { useProformaContext } from "../../context";
import { PercentageInputField } from "../components";
export const ProformaTotals = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<ProformaFormData>();
const { currency_code, language_code, readOnly, taxCatalog } = useProformaContext();
const displayTaxes = useWatch({ control, name: "taxes", defaultValue: [] });
const subtotal_amount = useWatch({ control, name: "subtotal_amount", defaultValue: 0 });
const items_discount_amount = useWatch({
control,
name: "items_discount_amount",
defaultValue: 0,
});
const discount_amount = useWatch({ control, name: "discount_amount", defaultValue: 0 });
const taxable_amount = useWatch({ control, name: "taxable_amount", defaultValue: 0 });
const taxes_amount = useWatch({ control, name: "taxes_amount", defaultValue: 0 });
const total_amount = useWatch({ control, name: "total_amount", defaultValue: 0 });
return (
<FieldSet {...props}>
<FieldLegend className="hidden">
<ReceiptIcon className="size-6 text-muted-foreground" />
{t("form_groups.totals.title")}
</FieldLegend>
<FieldDescription className="hidden">{t("form_groups.totals.description")}</FieldDescription>
<FieldGroup className="grid grid-cols-1 border rounded-lg bg-muted/10 p-4 gap-4">
<div className="space-y-1.5">
{/* Sección: Subtotal y Descuentos */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal sin descuentos</span>
<span className="font-medium tabular-nums text-muted-foreground">
{formatCurrency(subtotal_amount, 2, currency_code, language_code)}
</span>
</div>
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Descuento en líneas</span>
</div>
<span className="font-medium text-destructive tabular-nums">
-{formatCurrency(items_discount_amount, 2, currency_code, language_code)}
</span>
</div>
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Descuento global</span>
<PercentageInputField
className={cn(
"w-20 text-right tabular-nums bg-background",
"border-input border text-sm shadow-xs"
)}
control={control}
inputId={"discount-percentage"}
name={"discount_percentage"}
readOnly={readOnly}
showSuffix={true}
/>
</div>
<span className="font-medium text-destructive tabular-nums">
-{formatCurrency(discount_amount, 2, currency_code, language_code)}
</span>
</div>
{/* Sección: Base Imponible */}
<div className="flex justify-between text-sm">
<span className="text-foreground">Base imponible</span>
<span className="font-medium tabular-nums">
{formatCurrency(taxable_amount, 2, currency_code, language_code)}
</span>
</div>
</div>
<Separator />
{/* Sección: Impuestos */}
<div className="space-y-1.5">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Impuestos y retenciones
</h3>
{taxCatalog.groups().map((group) => {
// Filtra impuestos de ese grupo
const taxesInGroup = displayTaxes?.filter((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
() => undefined
);
return tax?.group === group;
});
// Si el grupo no tiene impuestos, no renderiza nada
if (taxesInGroup?.length === 0) return null;
return (
<div className="space-y-1.5 leading-3" key={`tax-group-${group}`}>
{taxesInGroup?.map((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
() => undefined
);
return (
<div
className="flex items-center justify-between text-sm"
key={`${group}:${item.tax_code}`}
>
<span className="text-muted-foreground text-sm">{tax?.name}</span>
<span className="font-medium tabular-nums text-sm text-muted-foreground">
{formatCurrency(item.taxes_amount, 2, currency_code, language_code)}
</span>
</div>
);
})}
</div>
);
})}
<div className="flex justify-between text-sm mt-3">
<span className="text-foreground">Total de impuestos</span>
<span className="font-medium tabular-nums">
{formatCurrency(taxes_amount, 2, currency_code, language_code)}
</span>
</div>
</div>
<Separator />
<div className="flex justify-between text-sm ">
<span className="font-bold text-foreground">Total de la factura</span>
<span className="font-bold tabular-nums">
{formatCurrency(total_amount, 2, currency_code, language_code)}
</span>
</div>
</FieldGroup>
</FieldSet>
);
};

View File

@ -1,2 +0,0 @@
export * from "./items-editor";
export * from "./proforma-editor-skeleton";

View File

@ -1,31 +0,0 @@
// components/CustomerSkeleton.tsx
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../../i18n";
export const ProformaEditorSkeleton = () => {
const { t } = useTranslation();
return (
<AppContent>
<div className="flex items-center justify-between">
<div aria-hidden="true" className="space-y-2">
<div className="h-7 w-64 rounded-md bg-muted animate-pulse" />
<div className="h-5 w-96 rounded-md bg-muted animate-pulse" />
</div>
<div className="flex items-center gap-2">
<BackHistoryButton />
<Button aria-busy disabled>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div aria-hidden="true" className="mt-6 grid gap-4">
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-10 w-full rounded-md bg-muted animate-pulse" />
<div className="h-28 w-full rounded-md bg-muted animate-pulse" />
</div>
<span className="sr-only">{t("pages.update.loading", "Cargando factura de cliente...")}</span>
</AppContent>
);
};

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@erp/customers",
"description": "Customers",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges",
"version": "0.7.6",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -0,0 +1,14 @@
# Identity Module
`identity` convivirá temporalmente con `@erp/auth`.
`@erp/auth` sigue siendo el runtime de autenticación actual.
`identity` incorporará progresivamente la implementación V1 aprobada:
- `email/password`
- `access token`
- `refresh token` persistido hasheado
- tenant activo mediante `X-Company-Id`
- roles y permisos company-scoped
No se deben registrar middlewares ni reemplazar dependencias de `@erp/auth` hasta que `identity` tenga implementación funcional validada.

View File

@ -0,0 +1,46 @@
{
"name": "@erp/identity",
"description": "Identity module",
"version": "0.7.8",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
},
"exports": {
".": "./src/common/index.ts",
"./common": "./src/common/index.ts",
"./api": "./src/api/index.ts"
},
"dependencies": {
"@erp/core": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.98.0",
"bcrypt": "^6.0.0",
"express": "^4.22.1",
"jsonwebtoken": "^9.0.3",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"sequelize": "^6.37.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
}
}

View File

@ -0,0 +1,14 @@
import type { EmailAddress } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { Account } from "../../../domain";
export interface IAccountRepository {
save(account: Account, transaction?: unknown): Promise<Result<void, Error>>;
update(account: Account, transaction?: unknown): Promise<Result<void, Error>>;
findById(id: Account["id"], transaction?: unknown): Promise<Result<Maybe<Account>, Error>>;
findByEmail(
email: EmailAddress,
transaction?: unknown
): Promise<Result<Maybe<Account>, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./account-repository.interface";

View File

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

View File

@ -0,0 +1,10 @@
import type { Result } from "@repo/rdx-utils";
export type AccessTokenPayload = {
accountId: string;
email: string;
};
export interface IAccessTokenIssuer {
issue(payload: AccessTokenPayload): Promise<Result<string, Error>>;
}

View File

@ -0,0 +1,10 @@
import type { Result } from "@repo/rdx-utils";
export type VerifiedAccessTokenPayload = {
accountId: string;
email: string;
};
export interface IAccessTokenVerifier {
verify(token: string): Promise<Result<VerifiedAccessTokenPayload, Error>>;
}

View File

@ -0,0 +1,6 @@
export * from "./access-token-issuer.interface";
export * from "./access-token-verifier.interface";
export * from "./password-hasher.interface";
export * from "./refresh-token-generator.interface";
export * from "./refresh-token-repository.interface";
export * from "./refresh-token-hasher.interface";

View File

@ -0,0 +1,12 @@
import type { Result } from "@repo/rdx-utils";
import type { PasswordHash } from "../../../domain";
export interface IPasswordHasher {
hash(password: string): Promise<Result<PasswordHash, Error>>;
verify(params: {
plainPassword: string;
passwordHash: PasswordHash;
}): Promise<Result<boolean, Error>>;
}

View File

@ -0,0 +1,5 @@
import type { Result } from "@repo/rdx-utils";
export interface IRefreshTokenGenerator {
generate(): Result<string, Error>;
}

View File

@ -0,0 +1,7 @@
import type { Result } from "@repo/rdx-utils";
import type { RefreshTokenHash } from "../../../domain";
export interface IRefreshTokenHasher {
hash(token: string): Result<RefreshTokenHash, Error>;
}

View File

@ -0,0 +1,18 @@
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { RefreshToken, RefreshTokenHash } from "../../../domain";
export interface IRefreshTokenRepository {
save(refreshToken: RefreshToken, transaction?: unknown): Promise<Result<void, Error>>;
update(refreshToken: RefreshToken, transaction?: unknown): Promise<Result<void, Error>>;
findByHash(
tokenHash: RefreshTokenHash,
transaction?: unknown
): Promise<Result<Maybe<RefreshToken>, Error>>;
revokeAllByAccountId(params: {
accountId: UniqueID;
revokedAt: UtcDateTime;
transaction?: unknown;
}): Promise<Result<number, Error>>;
}

View File

@ -0,0 +1,3 @@
export * from "./contracts";
export * from "./services";
export * from "./use-cases";

View File

@ -0,0 +1,70 @@
import type { EmailAddress } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { Account } from "../../../domain";
import type { IAccountRepository } from "../../accounts";
import type { IPasswordHasher } from "../contracts";
import { AccountDisabledError } from "./account-disabled.error";
import { AccountNotFoundError } from "./account-not-found.error";
import { InvalidCredentialsError } from "./invalid-credentials.error";
export type AuthenticateAccountWithPasswordParams = {
email: EmailAddress;
password: string;
transaction?: unknown;
};
export class AccountAuthenticator {
public constructor(
private readonly deps: {
accountRepository: IAccountRepository;
passwordHasher: IPasswordHasher;
}
) {}
public async authenticateWithPassword(
params: AuthenticateAccountWithPasswordParams
): Promise<Result<Account, Error>> {
const accountResult = await this.deps.accountRepository.findByEmail(
params.email,
params.transaction
);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(`Account not found for email "${params.email.toPrimitive()}".`)
);
}
const account = accountResult.data.unwrap();
const authenticationResult = account.canAuthenticate();
if (authenticationResult.isFailure) {
return Result.fail(
new AccountDisabledError("Account cannot authenticate in its current state.", {
cause: authenticationResult.error,
})
);
}
const passwordVerificationResult = await this.deps.passwordHasher.verify({
plainPassword: params.password,
passwordHash: account.passwordHash,
});
if (passwordVerificationResult.isFailure) {
return Result.fail(passwordVerificationResult.error);
}
if (!passwordVerificationResult.data) {
return Result.fail(new InvalidCredentialsError("Invalid credentials."));
}
return Result.ok(account);
}
}

View File

@ -0,0 +1,6 @@
export class AccountDisabledError extends Error {
public constructor(message = "Account is disabled.", options?: ErrorOptions) {
super(message, options);
this.name = "AccountDisabledError";
}
}

View File

@ -0,0 +1,6 @@
export class AccountNotFoundError extends Error {
public constructor(message = "Account not found.", options?: ErrorOptions) {
super(message, options);
this.name = "AccountNotFoundError";
}
}

View File

@ -0,0 +1,9 @@
export * from "./account-authenticator";
export * from "./account-disabled.error";
export * from "./account-not-found.error";
export * from "./invalid-credentials.error";
export * from "./issued-session";
export * from "./logout-requires-refresh-token.error";
export * from "./refresh-token-account-mismatch.error";
export * from "./refresh-token-not-found.error";
export * from "./session-issuer";

View File

@ -0,0 +1,6 @@
export class InvalidCredentialsError extends Error {
public constructor(message = "Invalid credentials.", options?: ErrorOptions) {
super(message, options);
this.name = "InvalidCredentialsError";
}
}

View File

@ -0,0 +1,5 @@
export type IssuedSession = {
accessToken: string;
refreshToken: string;
refreshTokenId: string;
};

View File

@ -0,0 +1,9 @@
export class LogoutRequiresRefreshTokenError extends Error {
public constructor(
message = "Logout requires a refresh token or allSessions=true.",
options?: ErrorOptions
) {
super(message, options);
this.name = "LogoutRequiresRefreshTokenError";
}
}

View File

@ -0,0 +1,6 @@
export class RefreshTokenAccountMismatchError extends Error {
public constructor(message = "Refresh token does not belong to the requested account.") {
super(message);
this.name = "RefreshTokenAccountMismatchError";
}
}

View File

@ -0,0 +1,6 @@
export class RefreshTokenNotFoundError extends Error {
public constructor(message = "Refresh token not found.", options?: ErrorOptions) {
super(message, options);
this.name = "RefreshTokenNotFoundError";
}
}

View File

@ -0,0 +1,83 @@
import { UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { type Account, RefreshToken } from "../../../domain";
import type {
IAccessTokenIssuer,
IRefreshTokenGenerator,
IRefreshTokenHasher,
IRefreshTokenRepository,
} from "../contracts";
import type { IssuedSession } from "./issued-session";
export type IssueSessionParams = {
account: Account;
refreshTokenExpiresAt: UtcDateTime;
now: UtcDateTime;
transaction?: unknown;
};
export class SessionIssuer {
public constructor(
private readonly deps: {
accessTokenIssuer: IAccessTokenIssuer;
refreshTokenGenerator: IRefreshTokenGenerator;
refreshTokenHasher: IRefreshTokenHasher;
refreshTokenRepository: IRefreshTokenRepository;
}
) {}
public async issue(params: IssueSessionParams): Promise<Result<IssuedSession, Error>> {
const accessTokenResult = await this.deps.accessTokenIssuer.issue({
accountId: params.account.id.toPrimitive(),
email: params.account.email.toPrimitive(),
});
if (accessTokenResult.isFailure) {
return Result.fail(accessTokenResult.error);
}
const plainRefreshTokenResult = this.deps.refreshTokenGenerator.generate();
if (plainRefreshTokenResult.isFailure) {
return Result.fail(plainRefreshTokenResult.error);
}
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(plainRefreshTokenResult.data);
if (refreshTokenHashResult.isFailure) {
return Result.fail(refreshTokenHashResult.error);
}
const refreshTokenId = UniqueID.generateNewID();
const refreshTokenCreateResult = RefreshToken.create(
{
accountId: params.account.id,
tokenHash: refreshTokenHashResult.data,
expiresAt: params.refreshTokenExpiresAt,
revokedAt: Maybe.none(),
replacedByTokenId: Maybe.none(),
createdAt: params.now,
},
refreshTokenId
);
if (refreshTokenCreateResult.isFailure) {
return Result.fail(refreshTokenCreateResult.error);
}
const saveResult = await this.deps.refreshTokenRepository.save(
refreshTokenCreateResult.data,
params.transaction
);
if (saveResult.isFailure) {
return Result.fail(saveResult.error);
}
return Result.ok({
accessToken: accessTokenResult.data,
refreshToken: plainRefreshTokenResult.data,
refreshTokenId: refreshTokenId.toPrimitive(),
});
}
}

View File

@ -0,0 +1,70 @@
import { Result } from "@repo/rdx-utils";
import type { UniqueID } from "@repo/rdx-ddd";
import type { IAccountRepository } from "../../accounts";
import { AccountDisabledError, AccountNotFoundError } from "../services";
export type GetCurrentSessionInput = {
accountId: UniqueID;
};
export type GetCurrentSessionOutput = {
account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
};
};
export class GetCurrentSessionUseCase {
public constructor(
private readonly deps: {
accountRepository: IAccountRepository;
}
) {}
public async execute(
input: GetCurrentSessionInput
): Promise<Result<GetCurrentSessionOutput, Error>> {
try {
const accountResult = await this.deps.accountRepository.findById(input.accountId);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(`Account not found for id "${input.accountId.toPrimitive()}".`)
);
}
const account = accountResult.data.unwrap();
const canAuthenticateResult = account.canAuthenticate();
if (canAuthenticateResult.isFailure) {
return Result.fail(
new AccountDisabledError("Account cannot authenticate in its current state.", {
cause: canAuthenticateResult.error,
})
);
}
return Result.ok({
account: {
id: account.id.toPrimitive(),
email: account.email.toPrimitive(),
name: account.name.toPrimitive(),
avatarUrl: account.avatarUrl.match(
(avatarUrl) => avatarUrl.toPrimitive(),
() => null
),
languageCode: account.languageCode.toPrimitive(),
},
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
}

View File

@ -0,0 +1,4 @@
export * from "./get-current-session.use-case";
export * from "./login-with-password.use-case";
export * from "./logout.use-case";
export * from "./refresh-session.use-case";

View File

@ -0,0 +1,85 @@
import type { ITransactionManager } from "@erp/core/api";
import { EmailAddress, type UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { AccountAuthenticator, SessionIssuer } from "../services";
export type LoginWithPasswordInput = {
email: string;
password: string;
now: UtcDateTime;
refreshTokenExpiresAt: UtcDateTime;
};
export type LoginWithPasswordOutput = {
accessToken: string;
refreshToken: string;
account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
};
};
export class LoginWithPasswordUseCase {
public constructor(
private readonly deps: {
accountAuthenticator: AccountAuthenticator;
sessionIssuer: SessionIssuer;
transactionManager: ITransactionManager;
}
) {}
public async execute(
input: LoginWithPasswordInput
): Promise<Result<LoginWithPasswordOutput, Error>> {
const emailResult = EmailAddress.create(input.email);
if (emailResult.isFailure) {
return Result.fail(emailResult.error);
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const accountResult = await this.deps.accountAuthenticator.authenticateWithPassword({
email: emailResult.data,
password: input.password,
transaction,
});
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
const sessionResult = await this.deps.sessionIssuer.issue({
account: accountResult.data,
refreshTokenExpiresAt: input.refreshTokenExpiresAt,
now: input.now,
transaction,
});
if (sessionResult.isFailure) {
return Result.fail(sessionResult.error);
}
return Result.ok({
accessToken: sessionResult.data.accessToken,
refreshToken: sessionResult.data.refreshToken,
account: {
id: accountResult.data.id.toPrimitive(),
email: accountResult.data.email.toPrimitive(),
name: accountResult.data.name.toPrimitive(),
avatarUrl: accountResult.data.avatarUrl.match(
(avatarUrl) => avatarUrl.toPrimitive(),
() => null
),
languageCode: accountResult.data.languageCode.toPrimitive(),
},
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,97 @@
import type { ITransactionManager } from "@erp/core/api";
import type { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import type { IRefreshTokenHasher, IRefreshTokenRepository } from "../contracts";
import {
LogoutRequiresRefreshTokenError,
RefreshTokenAccountMismatchError,
RefreshTokenNotFoundError,
} from "../services";
export type LogoutInput = {
accountId: UniqueID;
refreshToken?: string;
allSessions?: boolean;
now: UtcDateTime;
};
export type LogoutOutput = {
success: true;
};
export class LogoutUseCase {
public constructor(
private readonly deps: {
refreshTokenHasher: IRefreshTokenHasher;
refreshTokenRepository: IRefreshTokenRepository;
transactionManager: ITransactionManager;
}
) {}
public async execute(input: LogoutInput): Promise<Result<LogoutOutput, Error>> {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
if (input.allSessions === true) {
const revokeAllResult = await this.deps.refreshTokenRepository.revokeAllByAccountId({
accountId: input.accountId,
revokedAt: input.now,
transaction,
});
if (revokeAllResult.isFailure) {
return Result.fail(revokeAllResult.error);
}
return Result.ok({ success: true as const });
}
if (!input.refreshToken) {
return Result.fail(new LogoutRequiresRefreshTokenError());
}
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(input.refreshToken);
if (refreshTokenHashResult.isFailure) {
return Result.fail(refreshTokenHashResult.error);
}
const persistedRefreshTokenResult = await this.deps.refreshTokenRepository.findByHash(
refreshTokenHashResult.data,
transaction
);
if (persistedRefreshTokenResult.isFailure) {
return Result.fail(persistedRefreshTokenResult.error);
}
if (persistedRefreshTokenResult.data.isNone()) {
return Result.fail(new RefreshTokenNotFoundError());
}
const persistedRefreshToken = persistedRefreshTokenResult.data.unwrap();
if (persistedRefreshToken.accountId.toPrimitive() !== input.accountId.toPrimitive()) {
return Result.fail(new RefreshTokenAccountMismatchError());
}
const revokeResult = persistedRefreshToken.revoke({
revokedAt: input.now,
replacedByTokenId: Maybe.none(),
});
if (revokeResult.isFailure) {
return Result.fail(revokeResult.error);
}
const updateResult = await this.deps.refreshTokenRepository.update(
persistedRefreshToken,
transaction
);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok({ success: true as const });
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,126 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IAccountRepository } from "../../accounts";
import type { IRefreshTokenHasher, IRefreshTokenRepository } from "../contracts";
import type { SessionIssuer } from "../services";
import { AccountDisabledError, AccountNotFoundError, RefreshTokenNotFoundError } from "../services";
export type RefreshSessionInput = {
refreshToken: string;
now: UtcDateTime;
refreshTokenExpiresAt: UtcDateTime;
};
export type RefreshSessionOutput = {
accessToken: string;
refreshToken: string;
};
export class RefreshSessionUseCase {
public constructor(
private readonly deps: {
refreshTokenHasher: IRefreshTokenHasher;
refreshTokenRepository: IRefreshTokenRepository;
accountRepository: IAccountRepository;
sessionIssuer: SessionIssuer;
transactionManager: ITransactionManager;
}
) {}
public async execute(input: RefreshSessionInput): Promise<Result<RefreshSessionOutput, Error>> {
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const refreshTokenHashResult = this.deps.refreshTokenHasher.hash(input.refreshToken);
if (refreshTokenHashResult.isFailure) {
return Result.fail(refreshTokenHashResult.error);
}
const persistedRefreshTokenResult = await this.deps.refreshTokenRepository.findByHash(
refreshTokenHashResult.data,
transaction
);
if (persistedRefreshTokenResult.isFailure) {
return Result.fail(persistedRefreshTokenResult.error);
}
if (persistedRefreshTokenResult.data.isNone()) {
return Result.fail(new RefreshTokenNotFoundError());
}
const persistedRefreshToken = persistedRefreshTokenResult.data.unwrap();
const canBeUsedResult = persistedRefreshToken.canBeUsed(input.now);
if (canBeUsedResult.isFailure) {
return Result.fail(canBeUsedResult.error);
}
const accountResult = await this.deps.accountRepository.findById(
persistedRefreshToken.accountId,
transaction
);
if (accountResult.isFailure) {
return Result.fail(accountResult.error);
}
if (accountResult.data.isNone()) {
return Result.fail(
new AccountNotFoundError(
`Account not found for id "${persistedRefreshToken.accountId.toPrimitive()}".`
)
);
}
const account = accountResult.data.unwrap();
const canAuthenticateResult = account.canAuthenticate();
if (canAuthenticateResult.isFailure) {
return Result.fail(
new AccountDisabledError("Account cannot authenticate in its current state.", {
cause: canAuthenticateResult.error,
})
);
}
const newSessionResult = await this.deps.sessionIssuer.issue({
account,
refreshTokenExpiresAt: input.refreshTokenExpiresAt,
now: input.now,
transaction,
});
if (newSessionResult.isFailure) {
return Result.fail(newSessionResult.error);
}
const rotatedTokenIdResult = UniqueID.create(newSessionResult.data.refreshTokenId);
if (rotatedTokenIdResult.isFailure) {
return Result.fail(rotatedTokenIdResult.error);
}
const rotateResult = persistedRefreshToken.rotate({
revokedAt: input.now,
replacedByTokenId: rotatedTokenIdResult.data,
now: input.now,
});
if (rotateResult.isFailure) {
return Result.fail(rotateResult.error);
}
const updateResult = await this.deps.refreshTokenRepository.update(
persistedRefreshToken,
transaction
);
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok({
accessToken: newSessionResult.data.accessToken,
refreshToken: newSessionResult.data.refreshToken,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,9 @@
import type { Maybe, Result } from "@repo/rdx-utils";
import type { Company } from "../../../domain";
export interface ICompanyRepository {
save(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
update(company: Company, transaction?: unknown): Promise<Result<void, Error>>;
findById(id: Company["id"], transaction?: unknown): Promise<Result<Maybe<Company>, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./company-repository.interface";

View File

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

View File

@ -0,0 +1,21 @@
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { CompanyMembership } from "../../../domain";
export interface ICompanyMembershipRepository {
save(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
update(membership: CompanyMembership, transaction?: unknown): Promise<Result<void, Error>>;
findByAccountIdAndCompanyId(params: {
accountId: CompanyMembership["accountId"];
companyId: CompanyMembership["companyId"];
transaction?: unknown;
}): Promise<Result<Maybe<CompanyMembership>, Error>>;
findByCompanyId(params: {
companyId: CompanyMembership["companyId"];
transaction?: unknown;
}): Promise<Result<Collection<CompanyMembership>, Error>>;
findByAccountId(params: {
accountId: CompanyMembership["accountId"];
transaction?: unknown;
}): Promise<Result<Collection<CompanyMembership>, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./company-membership-repository.interface";

View File

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

View File

@ -0,0 +1,6 @@
export * from "./accounts";
export * from "./authentication";
export * from "./companies";
export * from "./company-memberships";
export * from "./permissions";
export * from "./roles";

View File

@ -0,0 +1 @@
export * from "./permission-catalog.interface";

View File

@ -0,0 +1,10 @@
import type { PermissionCode, PermissionDefinition } from "@erp/core";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
export interface IPermissionCatalog {
findAll(): Result<Collection<PermissionDefinition>, Error>;
exists(permissionCode: PermissionCode): Result<boolean, Error>;
findByCode(permissionCode: PermissionCode): Result<Maybe<PermissionDefinition>, Error>;
}

View File

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

View File

@ -0,0 +1,2 @@
export * from "./role-repository.interface";
export * from "./role-permission-validator.interface";

View File

@ -0,0 +1,6 @@
import type { PermissionCode } from "@erp/core";
import type { Result } from "@repo/rdx-utils";
export interface IRolePermissionValidator {
validate(permissionCodes: PermissionCode[]): Result<void, Error>;
}

View File

@ -0,0 +1,24 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Collection, Maybe, Result } from "@repo/rdx-utils";
import type { Role } from "../../../domain";
export interface IRoleRepository {
save(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
update(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
delete(role: Role, transaction?: unknown): Promise<Result<void, Error>>;
findByIdInCompany(params: {
roleId: UniqueID;
companyId: Role["companyId"];
transaction?: unknown;
}): Promise<Result<Maybe<Role>, Error>>;
findByIdsInCompany(params: {
roleIds: UniqueID[];
companyId: Role["companyId"];
transaction?: unknown;
}): Promise<Result<Collection<Role>, Error>>;
findByCompanyId(params: {
companyId: Role["companyId"];
transaction?: unknown;
}): Promise<Result<Collection<Role>, Error>>;
}

View File

@ -0,0 +1,2 @@
export * from "./contracts";
export * from "./services";

View File

@ -0,0 +1,2 @@
export * from "./role-permission-validator";
export * from "./unknown-permission-code.error";

View File

@ -0,0 +1,27 @@
import type { PermissionCode } from "@erp/core";
import { Result } from "@repo/rdx-utils";
import type { IPermissionCatalog } from "../../permissions";
import type { IRolePermissionValidator } from "../contracts";
import { UnknownPermissionCodeError } from "./unknown-permission-code.error";
export class RolePermissionValidator implements IRolePermissionValidator {
public constructor(private readonly permissionCatalog: IPermissionCatalog) {}
public validate(permissionCodes: PermissionCode[]): Result<void, Error> {
for (const permissionCode of permissionCodes) {
const existsResult = this.permissionCatalog.exists(permissionCode);
if (existsResult.isFailure) {
return Result.fail(existsResult.error);
}
if (!existsResult.data) {
return Result.fail(new UnknownPermissionCodeError(permissionCode));
}
}
return Result.ok(undefined);
}
}

View File

@ -0,0 +1,10 @@
import type { PermissionCode } from "@erp/core";
import { DomainError } from "@repo/rdx-ddd";
export class UnknownPermissionCodeError extends DomainError {
public readonly code = "IDENTITY_UNKNOWN_PERMISSION_CODE" as const;
public constructor(public readonly permissionCode: PermissionCode) {
super(`Unknown permission code: ${permissionCode}`);
}
}

View File

@ -0,0 +1,66 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
interface AccountStatusProps {
value: string;
}
export enum ACCOUNT_STATUS {
ACTIVE = "active",
DISABLED = "disabled",
PENDING_VERIFICATION = "pending_verification",
}
export class AccountStatus extends ValueObject<AccountStatusProps> {
private static readonly ALLOWED_STATUSES = [
ACCOUNT_STATUS.ACTIVE,
ACCOUNT_STATUS.DISABLED,
ACCOUNT_STATUS.PENDING_VERIFICATION,
] as const;
static create(value: string): Result<AccountStatus, Error> {
if (!AccountStatus.ALLOWED_STATUSES.includes(value as (typeof AccountStatus.ALLOWED_STATUSES)[number])) {
return Result.fail(
new DomainValidationError("INVALID_ACCOUNT_STATUS", "status", `Status value not valid: ${value}`)
);
}
return Result.ok(new AccountStatus({ value }));
}
static createActive(): AccountStatus {
return new AccountStatus({ value: ACCOUNT_STATUS.ACTIVE });
}
static createDisabled(): AccountStatus {
return new AccountStatus({ value: ACCOUNT_STATUS.DISABLED });
}
static createPendingVerification(): AccountStatus {
return new AccountStatus({ value: ACCOUNT_STATUS.PENDING_VERIFICATION });
}
isActive(): boolean {
return this.props.value === ACCOUNT_STATUS.ACTIVE;
}
isDisabled(): boolean {
return this.props.value === ACCOUNT_STATUS.DISABLED;
}
isPendingVerification(): boolean {
return this.props.value === ACCOUNT_STATUS.PENDING_VERIFICATION;
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,210 @@
import {
AggregateRoot,
type EmailAddress,
type LanguageCode,
type Name,
type URLAddress,
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { AccountStatus } from "./account-status.vo";
import { AccountCannotAuthenticateError, AccountCannotBeUpdatedError } from "./errors";
import type { PasswordHash } from "./password-hash";
export interface IAccountCreateProps {
email: EmailAddress;
passwordHash: PasswordHash;
name: Name;
avatarUrl: Maybe<URLAddress>;
languageCode: LanguageCode;
status: AccountStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
}
export type AccountProfilePatchProps = Partial<{
name: Name;
avatarUrl: Maybe<URLAddress>;
languageCode: LanguageCode;
}> & {
updatedAt: UtcDate;
};
export type AccountInternalProps = IAccountCreateProps;
export class Account extends AggregateRoot<AccountInternalProps> {
protected constructor(props: AccountInternalProps, id?: UniqueID) {
super(props, id);
}
static create(props: IAccountCreateProps, id?: UniqueID): Result<Account, Error> {
const validationResult = Account.validateCreateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new Account(props, id));
}
static rehydrate(props: AccountInternalProps, id: UniqueID): Account {
return new Account(props, id);
}
private static validateCreateProps(props: IAccountCreateProps): Result<void, Error> {
if (!props.email) {
return Result.fail(new Error("Account email is required."));
}
if (!props.passwordHash) {
return Result.fail(new Error("Account password hash is required."));
}
if (!props.name) {
return Result.fail(new Error("Account name is required."));
}
if (!props.languageCode) {
return Result.fail(new Error("Account language code is required."));
}
if (!props.status) {
return Result.fail(new Error("Account status is required."));
}
if (!(props.createdAt && props.updatedAt)) {
return Result.fail(new Error("Account timestamps are required."));
}
return Result.ok();
}
public get email(): EmailAddress {
return this.props.email;
}
public get passwordHash(): PasswordHash {
return this.props.passwordHash;
}
public get name(): Name {
return this.props.name;
}
public get avatarUrl(): Maybe<URLAddress> {
return this.props.avatarUrl;
}
public get languageCode(): LanguageCode {
return this.props.languageCode;
}
public get status(): AccountStatus {
return this.props.status;
}
public get createdAt(): UtcDate {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
return this.props.updatedAt;
}
public updateProfile(patch: AccountProfilePatchProps): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
}
let hasChanges = false;
if (patch.name && !this.props.name.equals(patch.name)) {
this.props.name = patch.name;
hasChanges = true;
}
if (
patch.avatarUrl !== undefined &&
!Account.sameMaybeValueObject(this.props.avatarUrl, patch.avatarUrl)
) {
this.props.avatarUrl = patch.avatarUrl;
hasChanges = true;
}
if (patch.languageCode && !this.props.languageCode.equals(patch.languageCode)) {
this.props.languageCode = patch.languageCode;
hasChanges = true;
}
if (!hasChanges) {
return Result.ok(false);
}
this.props.updatedAt = patch.updatedAt;
return Result.ok(true);
}
public changePasswordHash(params: {
passwordHash: PasswordHash;
updatedAt: UtcDate;
}): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.fail(new AccountCannotBeUpdatedError("Disabled accounts cannot be updated."));
}
if (this.props.passwordHash.equals(params.passwordHash)) {
return Result.ok(false);
}
this.props.passwordHash = params.passwordHash;
this.props.updatedAt = params.updatedAt;
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
this.props.status = AccountStatus.createDisabled();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isActive()) {
return Result.ok(false);
}
this.props.status = AccountStatus.createActive();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public canAuthenticate(): Result<boolean, Error> {
if (!this.status.isActive()) {
return Result.fail(
new AccountCannotAuthenticateError(
`Account cannot authenticate while status is "${this.status.toPrimitive()}".`
)
);
}
return Result.ok(true);
}
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
current: Maybe<T>,
next: Maybe<T>
): boolean {
return current.match(
(currentValue) =>
next.match(
(nextValue) => currentValue.equals(nextValue),
() => false
),
() => next.isNone()
);
}
}

View File

@ -0,0 +1,13 @@
import { DomainError } from "@repo/rdx-ddd";
export class InvalidPasswordHashError extends DomainError {
public readonly code = "IDENTITY_INVALID_PASSWORD_HASH" as const;
}
export class AccountCannotAuthenticateError extends DomainError {
public readonly code = "IDENTITY_ACCOUNT_CANNOT_AUTHENTICATE" as const;
}
export class AccountCannotBeUpdatedError extends DomainError {
public readonly code = "IDENTITY_ACCOUNT_CANNOT_BE_UPDATED" as const;
}

View File

@ -0,0 +1,4 @@
export * from "./account.aggregate";
export * from "./account-status.vo";
export * from "./errors";
export * from "./password-hash";

View File

@ -0,0 +1,55 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InvalidPasswordHashError } from "./errors";
interface PasswordHashProps {
value: string;
}
export class PasswordHash extends ValueObject<PasswordHashProps> {
private static readonly MIN_LENGTH = 20;
private static readonly MAX_LENGTH = 512;
static create(value: string): Result<PasswordHash, Error> {
const normalized = value.trim();
if (!normalized) {
return Result.fail(new InvalidPasswordHashError("Password hash is required."));
}
if (normalized.length < PasswordHash.MIN_LENGTH) {
return Result.fail(
new DomainValidationError(
"INVALID_PASSWORD_HASH",
"passwordHash",
`Password hash must be at least ${PasswordHash.MIN_LENGTH} characters long.`
)
);
}
if (normalized.length > PasswordHash.MAX_LENGTH) {
return Result.fail(
new DomainValidationError(
"INVALID_PASSWORD_HASH",
"passwordHash",
`Password hash must be at most ${PasswordHash.MAX_LENGTH} characters long.`
)
);
}
return Result.ok(new PasswordHash({ value: normalized }));
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,17 @@
import { DomainError } from "@repo/rdx-ddd";
export class InvalidRefreshTokenHashError extends DomainError {
public readonly code = "IDENTITY_INVALID_REFRESH_TOKEN_HASH" as const;
}
export class RefreshTokenExpiredError extends DomainError {
public readonly code = "IDENTITY_REFRESH_TOKEN_EXPIRED" as const;
}
export class RefreshTokenRevokedError extends DomainError {
public readonly code = "IDENTITY_REFRESH_TOKEN_REVOKED" as const;
}
export class RefreshTokenCannotBeRotatedError extends DomainError {
public readonly code = "IDENTITY_REFRESH_TOKEN_CANNOT_BE_ROTATED" as const;
}

View File

@ -0,0 +1,3 @@
export * from "./errors";
export * from "./refresh-token.aggregate";
export * from "./refresh-token-hash";

View File

@ -0,0 +1,55 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InvalidRefreshTokenHashError } from "./errors";
interface RefreshTokenHashProps {
value: string;
}
export class RefreshTokenHash extends ValueObject<RefreshTokenHashProps> {
private static readonly MIN_LENGTH = 20;
private static readonly MAX_LENGTH = 512;
static create(value: string): Result<RefreshTokenHash, Error> {
const normalized = value.trim();
if (!normalized) {
return Result.fail(new InvalidRefreshTokenHashError("Refresh token hash is required."));
}
if (normalized.length < RefreshTokenHash.MIN_LENGTH) {
return Result.fail(
new DomainValidationError(
"INVALID_REFRESH_TOKEN_HASH",
"tokenHash",
`Refresh token hash must be at least ${RefreshTokenHash.MIN_LENGTH} characters long.`
)
);
}
if (normalized.length > RefreshTokenHash.MAX_LENGTH) {
return Result.fail(
new DomainValidationError(
"INVALID_REFRESH_TOKEN_HASH",
"tokenHash",
`Refresh token hash must be at most ${RefreshTokenHash.MAX_LENGTH} characters long.`
)
);
}
return Result.ok(new RefreshTokenHash({ value: normalized }));
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,133 @@
import { AggregateRoot, type UniqueID, type UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, type Maybe as MaybeType, Result } from "@repo/rdx-utils";
import {
RefreshTokenCannotBeRotatedError,
RefreshTokenExpiredError,
RefreshTokenRevokedError,
} from "./errors";
import type { RefreshTokenHash } from "./refresh-token-hash";
export interface IRefreshTokenCreateProps {
accountId: UniqueID;
tokenHash: RefreshTokenHash;
expiresAt: UtcDateTime;
revokedAt: MaybeType<UtcDateTime>;
replacedByTokenId: MaybeType<UniqueID>;
createdAt: UtcDateTime;
}
export type RefreshTokenInternalProps = IRefreshTokenCreateProps;
export class RefreshToken extends AggregateRoot<RefreshTokenInternalProps> {
protected constructor(props: RefreshTokenInternalProps, id?: UniqueID) {
super(props, id);
}
static create(props: IRefreshTokenCreateProps, id?: UniqueID): Result<RefreshToken, Error> {
const validationResult = RefreshToken.validateCreateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new RefreshToken(props, id));
}
static rehydrate(props: RefreshTokenInternalProps, id: UniqueID): RefreshToken {
return new RefreshToken(props, id);
}
private static validateCreateProps(props: IRefreshTokenCreateProps): Result<void, Error> {
if (!props.accountId) {
return Result.fail(new Error("Refresh token account ID is required."));
}
if (!props.tokenHash) {
return Result.fail(new Error("Refresh token hash is required."));
}
if (!(props.expiresAt && props.createdAt)) {
return Result.fail(new Error("Refresh token timestamps are required."));
}
if (
RefreshToken.toEpochMillis(props.expiresAt) <= RefreshToken.toEpochMillis(props.createdAt)
) {
return Result.fail(new Error("Refresh token expiration must be later than creation."));
}
return Result.ok();
}
public get accountId(): UniqueID {
return this.props.accountId;
}
public get tokenHash(): RefreshTokenHash {
return this.props.tokenHash;
}
public get expiresAt(): UtcDateTime {
return this.props.expiresAt;
}
public get revokedAt(): Maybe<UtcDateTime> {
return this.props.revokedAt;
}
public get replacedByTokenId(): MaybeType<UniqueID> {
return this.props.replacedByTokenId;
}
public get createdAt(): UtcDateTime {
return this.props.createdAt;
}
public revoke(params: {
revokedAt: UtcDateTime;
replacedByTokenId?: Maybe<UniqueID>;
}): Result<boolean, Error> {
if (this.props.revokedAt.isSome()) {
return Result.ok(false);
}
this.props.revokedAt = Maybe.some(params.revokedAt);
this.props.replacedByTokenId = params.replacedByTokenId ?? Maybe.none<UniqueID>();
return Result.ok(true);
}
public rotate(params: {
revokedAt: UtcDateTime;
replacedByTokenId: UniqueID;
now: UtcDateTime;
}): Result<boolean, Error> {
const usableResult = this.canBeUsed(params.now);
if (usableResult.isFailure) {
return Result.fail(
new RefreshTokenCannotBeRotatedError("Refresh token cannot be rotated.", {
cause: usableResult.error,
})
);
}
this.props.revokedAt = Maybe.some(params.revokedAt);
this.props.replacedByTokenId = Maybe.some(params.replacedByTokenId);
return Result.ok(true);
}
public canBeUsed(now: UtcDateTime): Result<boolean, Error> {
if (this.props.revokedAt.isSome()) {
return Result.fail(new RefreshTokenRevokedError("Refresh token has been revoked."));
}
if (RefreshToken.toEpochMillis(this.props.expiresAt) <= RefreshToken.toEpochMillis(now)) {
return Result.fail(new RefreshTokenExpiredError("Refresh token has expired."));
}
return Result.ok(true);
}
private static toEpochMillis(value: UtcDateTime): number {
return new Date(value.toISOString()).getTime();
}
}

View File

@ -0,0 +1,53 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
interface CompanyStatusProps {
value: string;
}
export enum COMPANY_STATUS {
ACTIVE = "active",
DISABLED = "disabled",
}
export class CompanyStatus extends ValueObject<CompanyStatusProps> {
private static readonly ALLOWED_STATUSES = [COMPANY_STATUS.ACTIVE, COMPANY_STATUS.DISABLED] as const;
static create(value: string): Result<CompanyStatus, Error> {
if (!CompanyStatus.ALLOWED_STATUSES.includes(value as (typeof CompanyStatus.ALLOWED_STATUSES)[number])) {
return Result.fail(
new DomainValidationError("INVALID_COMPANY_STATUS", "status", `Status value not valid: ${value}`)
);
}
return Result.ok(new CompanyStatus({ value }));
}
static createActive(): CompanyStatus {
return new CompanyStatus({ value: COMPANY_STATUS.ACTIVE });
}
static createDisabled(): CompanyStatus {
return new CompanyStatus({ value: COMPANY_STATUS.DISABLED });
}
isActive(): boolean {
return this.props.value === COMPANY_STATUS.ACTIVE;
}
isDisabled(): boolean {
return this.props.value === COMPANY_STATUS.DISABLED;
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,169 @@
import {
AggregateRoot,
type Name,
type TextValue,
type UniqueID,
type UtcDate,
} from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils";
import { CompanyStatus } from "./company-status.vo";
import { CompanyCannotBeUpdatedError } from "./errors";
export interface ICompanyCreateProps {
ownerAccountId: UniqueID;
legalName: Name;
tradeName: Maybe<Name>;
tin: Maybe<TextValue>;
status: CompanyStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
}
export type CompanyPatchProps = Partial<{
legalName: Name;
tradeName: Maybe<Name>;
tin: Maybe<TextValue>;
}> & {
updatedAt: UtcDate;
};
export type CompanyInternalProps = ICompanyCreateProps;
export class Company extends AggregateRoot<CompanyInternalProps> {
protected constructor(props: CompanyInternalProps, id?: UniqueID) {
super(props, id);
}
static create(props: ICompanyCreateProps, id?: UniqueID): Result<Company, Error> {
const validationResult = Company.validateCreateProps(props);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
return Result.ok(new Company(props, id));
}
static rehydrate(props: CompanyInternalProps, id: UniqueID): Company {
return new Company(props, id);
}
private static validateCreateProps(props: ICompanyCreateProps): Result<void, Error> {
if (!props.ownerAccountId) {
return Result.fail(new Error("Company owner account ID is required."));
}
if (!props.legalName) {
return Result.fail(new Error("Company legal name is required."));
}
if (!props.status) {
return Result.fail(new Error("Company status is required."));
}
if (!(props.createdAt && props.updatedAt)) {
return Result.fail(new Error("Company timestamps are required."));
}
return Result.ok();
}
public get ownerAccountId(): UniqueID {
return this.props.ownerAccountId;
}
public get legalName(): Name {
return this.props.legalName;
}
public get tradeName(): Maybe<Name> {
return this.props.tradeName;
}
public get tin(): Maybe<TextValue> {
return this.props.tin;
}
public get status(): CompanyStatus {
return this.props.status;
}
public get createdAt(): UtcDate {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
return this.props.updatedAt;
}
public update(patch: CompanyPatchProps): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.fail(new CompanyCannotBeUpdatedError("Disabled companies cannot be updated."));
}
let hasChanges = false;
if (patch.legalName && !this.props.legalName.equals(patch.legalName)) {
this.props.legalName = patch.legalName;
hasChanges = true;
}
if (
patch.tradeName !== undefined &&
!Company.sameMaybeValueObject(this.props.tradeName, patch.tradeName)
) {
this.props.tradeName = patch.tradeName;
hasChanges = true;
}
if (patch.tin !== undefined && !Company.sameMaybeValueObject(this.props.tin, patch.tin)) {
this.props.tin = patch.tin;
hasChanges = true;
}
if (!hasChanges) {
return Result.ok(false);
}
this.props.updatedAt = patch.updatedAt;
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
this.props.status = CompanyStatus.createDisabled();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isActive()) {
return Result.ok(false);
}
this.props.status = CompanyStatus.createActive();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public isActive(): boolean {
return this.status.isActive();
}
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
current: Maybe<T>,
next: Maybe<T>
): boolean {
return current.match(
(currentValue) =>
next.match(
(nextValue) => currentValue.equals(nextValue),
() => false
),
() => next.isNone()
);
}
}

View File

@ -0,0 +1,5 @@
import { DomainError } from "@repo/rdx-ddd";
export class CompanyCannotBeUpdatedError extends DomainError {
public readonly code = "IDENTITY_COMPANY_CANNOT_BE_UPDATED" as const;
}

View File

@ -0,0 +1,3 @@
export * from "./company.aggregate";
export * from "./company-status.vo";
export * from "./errors";

View File

@ -0,0 +1,74 @@
import { DomainValidationError, ValueObject } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
interface CompanyMembershipStatusProps {
value: string;
}
export enum COMPANY_MEMBERSHIP_STATUS {
ACTIVE = "active",
DISABLED = "disabled",
INVITED = "invited",
}
export class CompanyMembershipStatus extends ValueObject<CompanyMembershipStatusProps> {
private static readonly ALLOWED_STATUSES = [
COMPANY_MEMBERSHIP_STATUS.ACTIVE,
COMPANY_MEMBERSHIP_STATUS.DISABLED,
COMPANY_MEMBERSHIP_STATUS.INVITED,
] as const;
static create(value: string): Result<CompanyMembershipStatus, Error> {
if (
!CompanyMembershipStatus.ALLOWED_STATUSES.includes(
value as (typeof CompanyMembershipStatus.ALLOWED_STATUSES)[number]
)
) {
return Result.fail(
new DomainValidationError(
"INVALID_COMPANY_MEMBERSHIP_STATUS",
"status",
`Status value not valid: ${value}`
)
);
}
return Result.ok(new CompanyMembershipStatus({ value }));
}
static createActive(): CompanyMembershipStatus {
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.ACTIVE });
}
static createDisabled(): CompanyMembershipStatus {
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.DISABLED });
}
static createInvited(): CompanyMembershipStatus {
return new CompanyMembershipStatus({ value: COMPANY_MEMBERSHIP_STATUS.INVITED });
}
isActive(): boolean {
return this.props.value === COMPANY_MEMBERSHIP_STATUS.ACTIVE;
}
isDisabled(): boolean {
return this.props.value === COMPANY_MEMBERSHIP_STATUS.DISABLED;
}
isInvited(): boolean {
return this.props.value === COMPANY_MEMBERSHIP_STATUS.INVITED;
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

View File

@ -0,0 +1,194 @@
import { AggregateRoot, type UniqueID, type UtcDate } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CompanyMembershipStatus } from "./company-membership-status.vo";
import {
CompanyMembershipCannotAccessCompanyError,
CompanyMembershipDuplicatedRoleError,
CompanyMembershipWithoutRolesError,
} from "./errors";
export interface ICompanyMembershipCreateProps {
accountId: UniqueID;
companyId: UniqueID;
roleIds: UniqueID[];
status: CompanyMembershipStatus;
createdAt: UtcDate;
updatedAt: UtcDate;
}
export type CompanyMembershipInternalProps = ICompanyMembershipCreateProps;
export class CompanyMembership extends AggregateRoot<CompanyMembershipInternalProps> {
protected constructor(props: CompanyMembershipInternalProps, id?: UniqueID) {
super(props, id);
}
static create(
props: ICompanyMembershipCreateProps,
id?: UniqueID
): Result<CompanyMembership, Error> {
const validationResult = CompanyMembership.validateState(props.status, props.roleIds);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
if (!(props.accountId && props.companyId)) {
return Result.fail(new Error("Company membership accountId and companyId are required."));
}
if (!(props.createdAt && props.updatedAt)) {
return Result.fail(new Error("Company membership timestamps are required."));
}
return Result.ok(new CompanyMembership(props, id));
}
static rehydrate(props: CompanyMembershipInternalProps, id: UniqueID): CompanyMembership {
return new CompanyMembership(props, id);
}
public get accountId(): UniqueID {
return this.props.accountId;
}
public get companyId(): UniqueID {
return this.props.companyId;
}
public get roleIds(): UniqueID[] {
return [...this.props.roleIds];
}
public get status(): CompanyMembershipStatus {
return this.props.status;
}
public get createdAt(): UtcDate {
return this.props.createdAt;
}
public get updatedAt(): UtcDate {
return this.props.updatedAt;
}
public replaceRoles(params: { roleIds: UniqueID[]; updatedAt: UtcDate }): Result<boolean, Error> {
const validationResult = CompanyMembership.validateRoleIds(params.roleIds);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
if (CompanyMembership.sameRoleIds(this.props.roleIds, params.roleIds)) {
return Result.ok(false);
}
this.props.roleIds = [...params.roleIds];
this.props.updatedAt = params.updatedAt;
return Result.ok(true);
}
public disable(updatedAt: UtcDate): Result<boolean, Error> {
if (this.status.isDisabled()) {
return Result.ok(false);
}
this.props.status = CompanyMembershipStatus.createDisabled();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public enable(updatedAt: UtcDate): Result<boolean, Error> {
const validationResult = CompanyMembership.validateRoleIds(this.props.roleIds);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
if (this.status.isActive()) {
return Result.ok(false);
}
this.props.status = CompanyMembershipStatus.createActive();
this.props.updatedAt = updatedAt;
return Result.ok(true);
}
public acceptInvitation(params: {
updatedAt: UtcDate;
roleIds?: UniqueID[];
}): Result<boolean, Error> {
const nextRoleIds = params.roleIds ? [...params.roleIds] : [...this.props.roleIds];
const validationResult = CompanyMembership.validateRoleIds(nextRoleIds);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
const sameStatus = this.status.isActive();
const sameRoles = CompanyMembership.sameRoleIds(this.props.roleIds, nextRoleIds);
if (sameStatus && sameRoles) {
return Result.ok(false);
}
this.props.roleIds = nextRoleIds;
this.props.status = CompanyMembershipStatus.createActive();
this.props.updatedAt = params.updatedAt;
return Result.ok(true);
}
public canAccessCompany(): Result<boolean, Error> {
const validationResult = CompanyMembership.validateState(this.status, this.props.roleIds);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
if (!this.status.isActive()) {
return Result.fail(
new CompanyMembershipCannotAccessCompanyError(
`Membership cannot access company while status is "${this.status.toPrimitive()}".`
)
);
}
return Result.ok(true);
}
private static validateState(
status: CompanyMembershipStatus,
roleIds: UniqueID[]
): Result<void, Error> {
const rolesValidation = CompanyMembership.validateRoleIds(roleIds, !status.isActive());
if (rolesValidation.isFailure) {
return Result.fail(rolesValidation.error);
}
return Result.ok();
}
private static validateRoleIds(roleIds: UniqueID[], allowEmpty = false): Result<void, Error> {
if (!allowEmpty && (!roleIds || roleIds.length === 0)) {
return Result.fail(
new CompanyMembershipWithoutRolesError("Active memberships must have at least one role.")
);
}
const uniqueCount = new Set((roleIds ?? []).map((roleId) => roleId.toPrimitive())).size;
if ((roleIds ?? []).length !== uniqueCount) {
return Result.fail(
new CompanyMembershipDuplicatedRoleError("Membership role IDs cannot contain duplicates.")
);
}
return Result.ok();
}
private static sameRoleIds(current: UniqueID[], next: UniqueID[]): boolean {
if (current.length !== next.length) {
return false;
}
const currentValues = current.map((roleId) => roleId.toPrimitive()).sort();
const nextValues = next.map((roleId) => roleId.toPrimitive()).sort();
return currentValues.every((value, index) => value === nextValues[index]);
}
}

View File

@ -0,0 +1,13 @@
import { DomainError } from "@repo/rdx-ddd";
export class CompanyMembershipWithoutRolesError extends DomainError {
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_WITHOUT_ROLES" as const;
}
export class CompanyMembershipDuplicatedRoleError extends DomainError {
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_DUPLICATED_ROLE" as const;
}
export class CompanyMembershipCannotAccessCompanyError extends DomainError {
public readonly code = "IDENTITY_COMPANY_MEMBERSHIP_CANNOT_ACCESS_COMPANY" as const;
}

View File

@ -0,0 +1,3 @@
export * from "./company-membership.aggregate";
export * from "./company-membership-status.vo";
export * from "./errors";

View File

@ -0,0 +1,5 @@
export * from "./accounts";
export * from "./authentication";
export * from "./companies";
export * from "./company-memberships";
export * from "./roles";

View File

@ -0,0 +1,25 @@
import { DomainError } from "@repo/rdx-ddd";
export class InvalidRoleCodeError extends DomainError {
public readonly code = "IDENTITY_INVALID_ROLE_CODE" as const;
}
export class InvalidPermissionCodeError extends DomainError {
public readonly code = "IDENTITY_INVALID_PERMISSION_CODE" as const;
}
export class RoleWithoutPermissionsError extends DomainError {
public readonly code = "IDENTITY_ROLE_WITHOUT_PERMISSIONS" as const;
}
export class RoleDuplicatedPermissionError extends DomainError {
public readonly code = "IDENTITY_ROLE_DUPLICATED_PERMISSION" as const;
}
export class RoleCannotBeUpdatedError extends DomainError {
public readonly code = "IDENTITY_ROLE_CANNOT_BE_UPDATED" as const;
}
export class RoleCannotBeDeletedError extends DomainError {
public readonly code = "IDENTITY_ROLE_CANNOT_BE_DELETED" as const;
}

View File

@ -0,0 +1,3 @@
export * from "./errors";
export * from "./role.aggregate";
export * from "./role-code";

View File

@ -0,0 +1,53 @@
import { ValueObject, translateZodValidationError } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { InvalidRoleCodeError } from "./errors";
interface RoleCodeProps {
value: string;
}
export class RoleCode extends ValueObject<RoleCodeProps> {
private static readonly MIN_LENGTH = 2;
private static readonly MAX_LENGTH = 100;
static create(value: string): Result<RoleCode, Error> {
const normalized = value.trim();
if (!normalized) {
return Result.fail(new InvalidRoleCodeError("Role code is required."));
}
const schema = z
.string()
.trim()
.regex(/^[a-z0-9-]+$/, {
message: "Role code must contain only lowercase letters, numbers, and hyphens.",
})
.min(RoleCode.MIN_LENGTH, {
message: `Role code must be at least ${RoleCode.MIN_LENGTH} characters long.`,
})
.max(RoleCode.MAX_LENGTH, {
message: `Role code must be at most ${RoleCode.MAX_LENGTH} characters long.`,
});
const result = schema.safeParse(normalized);
if (!result.success) {
return Result.fail(translateZodValidationError("RoleCode creation failed", result.error));
}
return Result.ok(new RoleCode({ value: result.data }));
}
getProps(): string {
return this.props.value;
}
toPrimitive(): string {
return this.getProps();
}
toString(): string {
return String(this.props.value);
}
}

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