feat(onboarding): implement initial onboarding process and validation

- Add tests for initial company input validation.
- Create hooks for managing initial onboarding status and provisioning.
- Implement orchestration for completing onboarding and restoring session.
- Develop UI for the initial onboarding page with form handling.
- Enhance routing to include onboarding navigation resolution.
- Introduce error handling for unprovisioned onboarding scenarios.
- Establish API endpoints for onboarding status and completion.
- Validate onboarding request payloads with appropriate schemas.
- Update onboarding module composition to include provisioning services.
This commit is contained in:
David Arranz 2026-09-09 11:19:41 +02:00
parent b2a9836436
commit dd6d33366e
29 changed files with 939 additions and 43 deletions

View File

@ -1 +1,2 @@
VITE_API_SERVER_URL=http://172.31.142.243:3002/api/v1
VITE_FORCE_INITIAL_ONBOARDING=true

View File

@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
completeInitialOnboarding,
getInitialOnboardingStatus,
provisionInitialOnboarding,
toCompleteInitialOnboardingDTO,
} from "./initial-onboarding.api";
const ds = {
custom: async (p: any) => {
if (p.method === "get") return { status: "pending", company_id: null };
if (p.path.endsWith("/complete")) {
assert.equal(p.data.address.street_2, "2B");
return { company_id: "c1" };
}
return { status: "pending", company_id: null };
},
} as any;
test("maps onboarding status and provision responses", async () => {
assert.deepEqual(await getInitialOnboardingStatus(ds), { status: "pending", companyId: null });
assert.deepEqual(await provisionInitialOnboarding(ds), { status: "pending", companyId: null });
});
test("maps complete payload and response", async () => {
const input: any = {
legalName: "Acme",
tradeName: "",
tin: "B1",
email: "",
phone: "",
website: "",
address: {
street: "Main",
street2: "2B",
city: "Madrid",
postalCode: "28001",
province: "Madrid",
countryCode: "ES",
},
};
assert.equal(await completeInitialOnboarding(ds, input), "c1");
assert.deepEqual(toCompleteInitialOnboardingDTO(input).address, {
street: "Main",
street_2: "2B",
city: "Madrid",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
});
});

View File

@ -0,0 +1,110 @@
import type { IDataSource } from "@erp/core/client";
export type InitialOnboardingStatus = "not_provisioned" | "pending" | "completed";
export interface InitialOnboardingStatusModel {
status: InitialOnboardingStatus;
companyId: string | null;
}
export interface InitialCompanyInput {
legalName: string;
tradeName?: string;
tin: string;
email?: string;
phone?: string;
website?: string;
address: {
street: string;
street2?: string;
city: string;
postalCode: string;
province: string;
countryCode: string;
};
}
interface StatusDTO {
status: InitialOnboardingStatus;
company_id: string | null;
}
interface CompleteResponseDTO {
company_id: string;
}
interface CompanyDTO {
legal_name: string;
trade_name?: string | null;
tin: string;
email?: string | null;
phone?: string | null;
website?: string | null;
address: {
street: string;
street_2?: string | null;
city: string;
postal_code: string;
province: string;
country_code: string;
};
}
const mapStatus = (v: StatusDTO): InitialOnboardingStatusModel => ({
status: v.status,
companyId: v.company_id,
});
export const toCompleteInitialOnboardingDTO = (v: InitialCompanyInput): CompanyDTO => ({
legal_name: v.legalName,
trade_name: v.tradeName || null,
tin: v.tin,
email: v.email || null,
phone: v.phone || null,
website: v.website || null,
address: {
street: v.address.street,
street_2: v.address.street2 || null,
city: v.address.city,
postal_code: v.address.postalCode,
province: v.address.province,
country_code: v.address.countryCode,
},
});
export const getInitialOnboardingStatus = (ds: IDataSource, signal?: AbortSignal) =>
ds
.custom<Record<string, never>, StatusDTO>({
path: "onboarding/initial",
method: "get",
signal,
data: {},
})
.then(mapStatus);
export const provisionInitialOnboarding = (ds: IDataSource, signal?: AbortSignal) =>
ds
.custom<Record<string, never>, StatusDTO>({
path: "onboarding/initial",
method: "post",
signal,
data: {},
})
.then(mapStatus);
export const completeInitialOnboarding = (
ds: IDataSource,
input: InitialCompanyInput,
signal?: AbortSignal
) =>
ds
.custom<CompanyDTO, CompleteResponseDTO>({
path: "onboarding/initial/complete",
method: "post",
signal,
data: toCompleteInitialOnboardingDTO(input),
})
.then((v) => v.company_id);
export const validateInitialCompanyInput = (value: InitialCompanyInput): string[] => {
const errors: string[] = [];
if (!value.legalName.trim()) errors.push("legalName");
if (!value.tin.trim()) errors.push("tin");
if (!value.address?.street.trim()) errors.push("address.street");
if (!value.address?.city.trim()) errors.push("address.city");
if (!value.address?.province.trim()) errors.push("address.province");
if (!/^\d{5}$/.test(value.address?.postalCode ?? "")) errors.push("address.postalCode");
if (!/^[A-Z]{2}$/.test(value.address?.countryCode ?? "")) errors.push("address.countryCode");
if (value.email && !/^\S+@\S+\.\S+$/.test(value.email)) errors.push("email");
return errors;
};

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { type InitialCompanyInput, validateInitialCompanyInput } from "./initial-onboarding.api";
const valid: InitialCompanyInput = {
legalName: "Acme",
tradeName: "",
tin: "B1",
email: "user@example.com",
phone: "",
website: "",
address: {
street: "Main",
street2: "",
city: "Madrid",
postalCode: "28001",
province: "Madrid",
countryCode: "ES",
},
};
for (const [name, mutate] of Object.entries({
valid: () => valid,
legalName: () => ({ ...valid, legalName: "" }),
tin: () => ({ ...valid, tin: "" }),
address: () => ({ ...valid, address: undefined as never }),
street: () => ({ ...valid, address: { ...valid.address, street: "" } }),
city: () => ({ ...valid, address: { ...valid.address, city: "" } }),
province: () => ({ ...valid, address: { ...valid.address, province: "" } }),
postalCode: () => ({ ...valid, address: { ...valid.address, postalCode: "bad" } }),
countryCode: () => ({ ...valid, address: { ...valid.address, countryCode: "ESP" } }),
email: () => ({ ...valid, email: "bad" }),
}))
test(name, () => {
const errors = validateInitialCompanyInput(mutate());
if (name === "valid") assert.deepEqual(errors, []);
else assert.ok(errors.length > 0);
});

View File

@ -0,0 +1,33 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type InitialCompanyInput,
completeInitialOnboarding,
getInitialOnboardingStatus,
provisionInitialOnboarding,
} from "../api/initial-onboarding.api";
export const initialOnboardingStatusKey = ["onboarding", "initial"] as const;
export const useInitialOnboardingStatus = () => {
const ds = useDataSource();
return useQuery({
queryKey: initialOnboardingStatusKey,
queryFn: ({ signal }) => getInitialOnboardingStatus(ds, signal),
});
};
export const useProvisionInitialOnboarding = () => {
const ds = useDataSource();
const qc = useQueryClient();
return useMutation({
mutationFn: () => provisionInitialOnboarding(ds),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v),
});
};
export const useCompleteInitialOnboarding = () => {
const ds = useDataSource();
const qc = useQueryClient();
return useMutation({
mutationFn: (v: InitialCompanyInput) => completeInitialOnboarding(ds, v),
onSuccess: (v) => qc.setQueryData(initialOnboardingStatusKey, v),
});
};

View File

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { completeInitialOnboardingAndRestoreSession } from "./complete-initial-onboarding-and-restore-session";
test("selects created company among multiple companies", async () => {
let selected = "";
const r = await completeInitialOnboardingAndRestoreSession("x", {
restoreSession: async () => ({}),
getAvailableCompanies: () => [{ id: "a" }, { id: "x" }],
changeActiveCompany: async (id) => {
selected = id;
return { id };
},
});
assert.deepEqual(r, { kind: "application", companyId: "x" });
assert.equal(selected, "x");
});
test("reports missing company without selecting another", async () => {
let selected = false;
const r = await completeInitialOnboardingAndRestoreSession("x", {
restoreSession: async () => ({}),
getAvailableCompanies: () => [{ id: "a" }, { id: "b" }],
changeActiveCompany: async () => {
selected = true;
return null;
},
});
assert.deepEqual(r, { kind: "recoverable", companyId: "x", reason: "company_missing" });
assert.equal(selected, false);
});
test("reports restore failure without repeating completion", async () => {
const r = await completeInitialOnboardingAndRestoreSession("x", {
restoreSession: async () => null,
getAvailableCompanies: () => [],
changeActiveCompany: async () => null,
});
assert.deepEqual(r, { kind: "recoverable", companyId: "x", reason: "restore_failed" });
});

View File

@ -0,0 +1,27 @@
export interface RestoredCompany {
id: string;
}
export interface OnboardingSessionActions {
restoreSession: () => Promise<unknown>;
getAvailableCompanies: () => RestoredCompany[];
changeActiveCompany: (companyId: string) => Promise<RestoredCompany | null>;
}
export type CompletionTransition =
| { kind: "application"; companyId: string }
| { kind: "recoverable"; companyId: string; reason: "company_missing" | "restore_failed" };
export async function completeInitialOnboardingAndRestoreSession(
companyId: string,
actions: OnboardingSessionActions
): Promise<CompletionTransition> {
try {
const restored = await actions.restoreSession();
if (restored === null) return { kind: "recoverable", companyId, reason: "restore_failed" };
} catch {
return { kind: "recoverable", companyId, reason: "restore_failed" };
}
const companies = actions.getAvailableCompanies();
const created = companies.find((company) => company.id === companyId);
if (!created) return { kind: "recoverable", companyId, reason: "company_missing" };
if (companies.length > 1) await actions.changeActiveCompany(companyId);
return { kind: "application", companyId };
}

View File

@ -0,0 +1,115 @@
import { useIdentityAuthSession } from "@erp/identity/client";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import { useEffect, useState } from "react";
import {
type InitialCompanyInput,
useCompleteInitialOnboarding,
} from "../hooks/use-initial-onboarding";
const empty: InitialCompanyInput = {
legalName: "",
tradeName: "",
tin: "",
email: "",
phone: "",
website: "",
address: { street: "", street2: "", city: "", postalCode: "", province: "", countryCode: "ES" },
};
export const InitialOnboardingPage = () => {
const [v, setV] = useState(empty);
const [recoverableError, setRecoverableError] = useState<string | null>(null);
const m = useCompleteInitialOnboarding();
const { restoreSession, availableCompanies, changeActiveCompany } = useIdentityAuthSession();
useEffect(() => {
if (!m.data) return;
const found = availableCompanies.find((c) => c.id === m.data);
if (!found) {
if (availableCompanies.length > 0) setRecoverableError("company_missing");
return;
}
if (availableCompanies.length > 1)
changeActiveCompany(m.data).catch(() => setRecoverableError("company_selection_failed"));
}, [m.data, availableCompanies, changeActiveCompany]);
const set = (k: string, x: string) => setV((s) => ({ ...s, [k]: x }));
const setA = (k: string, x: string) => setV((s) => ({ ...s, address: { ...s.address, [k]: x } }));
if (m.isSuccess) return <LoadingOverlay />;
return (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col justify-center gap-6 p-6">
<div>
<h1 className="text-3xl font-semibold">Configura tu empresa</h1>
<p className="text-muted-foreground">Completa los datos fiscales para comenzar.</p>
</div>
<form
className="grid gap-4"
onSubmit={async (e) => {
e.preventDefault();
await m.mutateAsync(v);
await restoreSession();
}}
>
{[
["legalName", "Razón social", true],
["tradeName", "Nombre comercial", false],
["tin", "NIF/CIF", true],
["email", "Email", false],
["phone", "Teléfono", false],
["website", "Web", false],
].map(([k, l, r]) => (
<label className="grid gap-1" key={k as string}>
<span>
{l as string}
{r ? " *" : ""}
</span>
<input
className="rounded border p-2"
onChange={(e) => set(k as string, e.target.value)}
required={Boolean(r)}
value={String(v[k as keyof InitialCompanyInput] ?? "")}
/>
</label>
))}
<h2 className="mt-4 text-xl font-medium">Dirección fiscal</h2>
{[
["street", "Calle", true],
["street2", "Complemento", false],
["city", "Ciudad", true],
["postalCode", "Código postal", true],
["province", "Provincia", true],
["countryCode", "País", true],
].map(([k, l, r]) => (
<label className="grid gap-1" key={k as string}>
<span>
{l as string}
{r ? " *" : ""}
</span>
<input
className="rounded border p-2"
onChange={(e) => setA(k as string, e.target.value)}
required={Boolean(r)}
value={String(v.address[k as keyof typeof v.address] ?? "")}
/>
</label>
))}
{recoverableError && (
<p className="text-red-600">
No se pudo restaurar la empresa ({recoverableError}). Puedes reintentar la restauración
o cerrar sesión.
</p>
)}
{m.isError && (
<p className="text-red-600">
No se pudo completar el alta. Revisa los datos e inténtalo de nuevo.
</p>
)}
<button
className="rounded bg-primary px-4 py-2 text-primary-foreground"
disabled={m.isPending}
type="submit"
>
{m.isPending ? "Guardando…" : "Crear empresa"}
</button>
</form>
</main>
);
};

View File

@ -1 +1,2 @@
export * from "./app-routes";
export * from "./resolve-initial-onboarding-navigation";

View File

@ -1,29 +1,64 @@
import { useIdentityAuthSession } from "@erp/identity/client";
import { LoadingOverlay } from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
import { Navigate, useLocation } from "react-router-dom";
import {
useInitialOnboardingStatus,
useProvisionInitialOnboarding,
} from "@/onboarding/hooks/use-initial-onboarding";
import { InitialOnboardingPage } from "@/onboarding/ui/initial-onboarding-page";
import { resolveCompanyGuardDecision } from "./resolve-company-guard-decision";
interface RequireCompanyRouteGuardProps {
children: ReactNode;
}
export const RequireCompanyRouteGuard = ({ children }: RequireCompanyRouteGuardProps) => {
const location = useLocation();
const { activeCompany, availableCompanies, isLoading } = useIdentityAuthSession();
if (isLoading) {
const statusQuery = useInitialOnboardingStatus();
const provision = useProvisionInitialOnboarding();
useEffect(() => {
if (
availableCompanies.length === 0 &&
statusQuery.data?.status === "not_provisioned" &&
!provision.isPending &&
!provision.isSuccess &&
!provision.isError
)
provision.mutate();
}, [
availableCompanies.length,
statusQuery.data?.status,
provision.isPending,
provision.isSuccess,
provision.mutate,
provision.isError,
]);
if (
isLoading ||
(availableCompanies.length === 0 && (statusQuery.isLoading || provision.isPending))
)
return <LoadingOverlay />;
}
if (activeCompany) {
return <>{children}</>;
}
const navigation = statusQuery.data
? resolveCompanyGuardDecision({
isAuthenticated: true,
availableCompaniesCount: availableCompanies.length,
onboardingStatus: statusQuery.data.status,
})
: null;
if (activeCompany || navigation === "application") return <>{children}</>;
if (statusQuery.isError || provision.isError)
return <div className="p-8">No se pudo cargar el estado de onboarding.</div>;
if (statusQuery.data?.status === "pending" || provision.isSuccess)
return <InitialOnboardingPage />;
if (statusQuery.data?.status === "completed")
return (
<div className="p-8">
La cuenta tiene un onboarding completado, pero no hay una empresa disponible.
</div>
);
const returnTo = `${location.pathname}${location.search}`;
if (availableCompanies.length === 0) {
return <Navigate replace to="/no-companies" />;
}
return <Navigate replace to={`/company-selection?returnTo=${encodeURIComponent(returnTo)}`} />;
};

View File

@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveCompanyGuardDecision } from "./resolve-company-guard-decision";
const d = (n: number, s: any, a = false) =>
resolveCompanyGuardDecision({
isAuthenticated: true,
availableCompaniesCount: n,
hasActiveCompany: a,
onboardingStatus: s,
});
test("companies have priority", () => {
assert.equal(d(2, "pending"), "application");
assert.equal(d(2, "pending"), "application");
});
test("zero companies route by onboarding state", () => {
assert.equal(d(0, "pending"), "onboarding");
assert.equal(d(0, "not_provisioned"), "provision_onboarding");
assert.equal(d(0, "completed"), "onboarding_inconsistent");
});
test("unauthenticated goes to login", () =>
assert.equal(
resolveCompanyGuardDecision({
isAuthenticated: false,
availableCompaniesCount: 0,
hasActiveCompany: false,
onboardingStatus: "pending",
}),
"login"
));

View File

@ -0,0 +1,15 @@
import {
type InitialOnboardingNavigation,
resolveInitialOnboardingNavigation,
} from "./resolve-initial-onboarding-navigation";
export const resolveCompanyGuardDecision = (input: {
isAuthenticated: boolean;
availableCompaniesCount: number;
hasActiveCompany: boolean;
onboardingStatus: "not_provisioned" | "pending" | "completed";
}): InitialOnboardingNavigation =>
resolveInitialOnboardingNavigation({
isAuthenticated: input.isAuthenticated,
availableCompaniesCount: input.availableCompaniesCount,
onboardingStatus: input.onboardingStatus,
});

View File

@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveInitialOnboardingNavigation } from "./resolve-initial-onboarding-navigation";
const cases = [
[false, 0, "pending", "login"],
[true, 1, "pending", "application"],
[true, 1, "completed", "application"],
[true, 1, "not_provisioned", "application"],
[true, 0, "pending", "onboarding"],
[true, 0, "not_provisioned", "provision_onboarding"],
[true, 0, "completed", "onboarding_inconsistent"],
[true, 2, "pending", "application"],
] as const;
for (const [isAuthenticated, companies, status, expected] of cases) {
test(`${String(isAuthenticated)} + ${companies} companies + ${status}`, () => {
assert.equal(
resolveInitialOnboardingNavigation({
isAuthenticated,
availableCompaniesCount: companies,
onboardingStatus: status,
}),
expected
);
});
}
test("pending with available companies has no side effects", () => {
const input = {
isAuthenticated: true,
availableCompaniesCount: 1,
onboardingStatus: "pending" as const,
};
assert.equal(resolveInitialOnboardingNavigation(input), "application");
assert.deepEqual(input, {
isAuthenticated: true,
availableCompaniesCount: 1,
onboardingStatus: "pending",
});
});

View File

@ -0,0 +1,23 @@
export type InitialOnboardingNavigation =
| "login"
| "application"
| "onboarding"
| "provision_onboarding"
| "onboarding_inconsistent";
export type InitialOnboardingNavigationInput = {
isAuthenticated: boolean;
availableCompaniesCount: number;
onboardingStatus: "not_provisioned" | "pending" | "completed";
};
/** Decide la navegación sin leer ni modificar persistencia. */
export function resolveInitialOnboardingNavigation(
input: InitialOnboardingNavigationInput
): InitialOnboardingNavigation {
if (!input.isAuthenticated) return "login";
if (input.availableCompaniesCount > 0) return "application";
if (input.onboardingStatus === "pending") return "onboarding";
if (input.onboardingStatus === "completed") return "onboarding_inconsistent";
return "provision_onboarding";
}

View File

@ -0,0 +1,9 @@
import { ApplicationError } from "@repo/rdx-ddd";
/** La transición requiere un onboarding provisionado previamente para la cuenta. */
export class InitialOnboardingNotProvisionedError extends ApplicationError {
constructor() {
super("Initial onboarding has not been provisioned.", "initial_onboarding_not_provisioned");
this.name = "InitialOnboardingNotProvisionedError";
}
}

View File

@ -1 +1,5 @@
export type IOnboardingPublicServices = Record<never, never>;
import type { InitialOnboardingProvisioner } from "./initial-onboarding-provisioner";
export interface IOnboardingPublicServices {
provisioner: InitialOnboardingProvisioner;
}

View File

@ -6,6 +6,7 @@ import { UniqueID, UtcDateTime } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { InitialOnboarding } from "../../domain";
import { InitialOnboardingNotProvisionedError } from "../errors/initial-onboarding-not-provisioned-error";
import { CompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
import { CompleteInitialOnboardingUseCase } from "./complete-initial-onboarding.use-case";
@ -137,7 +138,10 @@ test("completed existente ignora payload nuevo", async () => {
});
test("ausente falla sin provisionar", async () => {
const s = setup(undefined, true);
assert.equal((await s.useCase.execute(input)).isFailure, true);
const result = await s.useCase.execute(input);
assert.equal(result.isFailure, true);
assert.ok(result.error instanceof InitialOnboardingNotProvisionedError);
assert.equal(result.error.code, "initial_onboarding_not_provisioned");
assert.deepEqual(s.events, ["begin", "lock", "rollback"]);
});
for (const failure of ["read", "company", "admin", "save", "throw", "commit", "begin"]) {

View File

@ -6,6 +6,7 @@ import { Result } from "@repo/rdx-utils";
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
import type { InitialOnboarding } from "../../domain";
import { InitialOnboardingNotProvisionedError } from "../errors/initial-onboarding-not-provisioned-error";
import type { ICompleteInitialOnboardingInputMapper } from "../mappers/complete-initial-onboarding-input.mapper";
import type { IInitialOnboardingRepository } from "../repositories/initial-onboarding-repository.interface";
@ -45,8 +46,7 @@ export class CompleteInitialOnboardingUseCase {
transaction
);
if (found.isFailure) return Result.fail(found.error);
if (found.data.isNone())
return Result.fail(new Error("Initial onboarding has not been provisioned."));
if (found.data.isNone()) return Result.fail(new InitialOnboardingNotProvisionedError());
const onboarding = found.data.unwrap();
if (!onboarding.accountId.equals(params.accountId))
return Result.fail(new Error("Initial onboarding account mismatch."));

View File

@ -15,7 +15,7 @@ export const onboardingAPIModule: IModuleServer = {
dependencies: ["companies", "identity"],
setup(params) {
const internal = buildOnboardingDependencies(params);
const general: IOnboardingPublicServices = {};
const general: IOnboardingPublicServices = internal.applicationServices;
return { models, services: { general }, internal };
},
start(params) {

View File

@ -1,18 +1,22 @@
import { ExpressController } from "@erp/core/api";
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { CompleteInitialOnboardingRequestDTO } from "../../../common";
import type { CompleteInitialOnboardingUseCase } from "../../application";
import { onboardingApiErrorMapper } from "./onboarding-api-error-mapper";
export class CompleteInitialOnboardingController extends ExpressController {
constructor(private readonly useCase: CompleteInitialOnboardingUseCase) {
super();
this.errorMapper = onboardingApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) return this.unauthorizedError("Unauthorized");
const dto = this.req.body as CompleteInitialOnboardingRequestDTO;
const result = await this.useCase.execute({ dto, accountId: user.userId });
const result = await this.useCase.execute({ dto, accountId: user.userId.toPrimitive() });
return result.match(
(value) => this.ok({ company_id: value.companyId }),
(error) => this.handleError(error)

View File

@ -1,10 +1,11 @@
import { ExpressController } from "@erp/core/api";
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { GetInitialOnboardingStatusUseCase } from "../../application";
export class GetInitialOnboardingStatusController extends ExpressController {
constructor(private readonly useCase: GetInitialOnboardingStatusUseCase) {
super();
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {

View File

@ -0,0 +1,14 @@
import { ApiErrorMapper, ConflictApiError } from "@erp/core/api";
import { InitialOnboardingNotProvisionedError } from "../../application/errors/initial-onboarding-not-provisioned-error";
export const onboardingApiErrorMapper = ApiErrorMapper.default().register({
priority: 120,
matches: (error) => error instanceof InitialOnboardingNotProvisionedError,
build: (error) => {
const functionalError = error as InitialOnboardingNotProvisionedError;
const conflict = new ConflictApiError(functionalError.message);
conflict.errors = [{ code: functionalError.code, message: functionalError.message }];
return conflict;
},
});

View File

@ -0,0 +1,244 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import type { TestContext } from "node:test";
import test from "node:test";
import type { StartParams } from "@erp/core/api";
import { ApiErrorMapper, globalErrorHandler, toProblemJson } from "@erp/core/api";
import { requireAuthenticated } from "@erp/identity/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { RequestHandler } from "express";
import express from "express";
import { InitialOnboardingNotProvisionedError } from "../../application/errors/initial-onboarding-not-provisioned-error";
import type { CompleteInitialOnboardingUseCaseInput } from "../../application/use-cases/complete-initial-onboarding.use-case";
import { InitialOnboarding } from "../../domain";
import { onboardingRouter } from "./onboarding.routes";
const accountId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const companyId = "33333333-3333-4333-8333-333333333333";
const path = "/api/v1/onboarding/initial/complete";
const valid = {
legal_name: "Company A",
tin: "B12345678",
address: {
street: "Calle Mayor 1",
city: "Madrid",
postal_code: "28001",
province: "Madrid",
country_code: "ES",
},
};
async function runtime(t: TestContext, error?: Error) {
const app = express();
app.use(express.json());
const calls: CompleteInitialOnboardingUseCaseInput[] = [];
let status: "not_provisioned" | "pending" | "completed" = "not_provisioned";
let provisionCalls = 0;
const authenticate: RequestHandler = (req, _res, next) => {
// Solo se sustituye la resolución de credenciales; no se crea contexto de Company.
if (req.get("authorization") === "Test account-a") {
Object.assign(req, { user: { userId: accountId } });
}
next();
};
const unexpected = () => {
throw new Error("Unexpected HTTP side effect");
};
const params: StartParams = {
app,
config: { server: { apiBasePath: "/api/v1" } },
operationContext: {} as never,
transactionContext: { run: unexpected } as never,
audit: {} as never,
getInternal: <T>(name: string) => {
assert.equal(name, "onboarding");
return {
applicationServices: {
provisioner: {
provision: async (id: typeof accountId) => {
provisionCalls++;
return Result.ok(InitialOnboarding.createPending(id).data);
},
},
},
useCases: {
completeInitialOnboarding: {
execute: async (input: CompleteInitialOnboardingUseCaseInput) => {
calls.push(input);
return error ? Result.fail(error) : Result.ok({ companyId });
},
},
getInitialOnboardingStatus: {
execute: async () =>
Result.ok({ status, companyId: status === "completed" ? companyId : null }),
},
},
repository: { save: unexpected, findByAccountIdForUpdate: unexpected },
} as T;
},
getService: <T>(name: string) => {
if (name === "companies:general") return { finder: {} } as T;
assert.equal(name, "identity:general");
return {
auth: {
authenticatedOnly: () => [authenticate, requireAuthenticated()],
tenantRequired: () => {
throw new Error("Onboarding must not require Company context");
},
},
} as T;
},
};
onboardingRouter(params);
app.use(globalErrorHandler);
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(
() =>
new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))
);
const address = server.address() as AddressInfo;
return {
calls,
setStatus: (value: typeof status) => {
status = value;
},
provisionCalls: () => provisionCalls,
async request(body: unknown = valid, authenticated = true, url = path, method = "POST") {
const response = await fetch(`http://127.0.0.1:${address.port}${url}`, {
method,
headers: {
"content-type": "application/json",
...(authenticated ? { authorization: "Test account-a" } : {}),
},
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(5000),
});
return { status: response.status, body: await response.json() };
},
};
}
test("POST happy path: snake_case exacto, account auth y sin Company", async (t) => {
const r = await runtime(t);
const response = await r.request();
assert.equal(response.status, 200);
assert.deepEqual(response.body, { company_id: companyId });
assert.equal(r.calls.length, 1);
assert.equal(r.calls[0].accountId, accountId.toPrimitive());
assert.deepEqual(r.calls[0].dto, valid);
assert.equal(r.provisionCalls(), 0);
});
test("POST unauthorized no llega a Application", async (t) => {
const r = await runtime(t);
assert.equal((await r.request(valid, false)).status, 401);
assert.equal(r.calls.length, 0);
});
const invalid: [string, unknown][] = [
["legal_name ausente", { ...valid, legal_name: undefined }],
["address ausente", { ...valid, address: undefined }],
["address parcial", { ...valid, address: { ...valid.address, city: undefined } }],
["country_code inválido", { ...valid, address: { ...valid.address, country_code: "12" } }],
["postal_code inválido", { ...valid, address: { ...valid.address, postal_code: "!!!!!" } }],
["unknown field", { ...valid, company_id: companyId }],
["account_id externo", { ...valid, account_id: companyId }],
];
for (const [name, body] of invalid) {
test(`POST ${name}: 400 sin ejecutar use case`, async (t) => {
const r = await runtime(t);
assert.equal((await r.request(body)).status, 400);
assert.equal(r.calls.length, 0);
});
}
test("POST query accountId no sustituye auth", async (t) => {
const r = await runtime(t);
assert.equal((await r.request(valid, true, `${path}?accountId=${companyId}`)).status, 200);
assert.equal(r.calls[0].accountId, accountId.toPrimitive());
});
for (const [name, error] of [
["Application failure", new Error("Test application failure")],
] as const) {
test(`POST ${name}: mapper estándar sin provisioning`, async (t) => {
const r = await runtime(t, error);
const response = await r.request();
const expected = ApiErrorMapper.default().map(error);
assert.equal(response.status, expected.status);
assert.deepEqual(response.body, toProblemJson(expected, { instance: path, method: "POST" }));
assert.equal(r.calls.length, 1);
assert.equal(r.provisionCalls(), 0);
});
}
test("POST repetido y payload distinto mantienen 200 y company_id", async (t) => {
const r = await runtime(t);
for (const body of [valid, valid, { ...valid, legal_name: "Company B" }]) {
const response = await r.request(body);
assert.equal(response.status, 200);
assert.deepEqual(response.body, { company_id: companyId });
}
assert.equal(r.calls.length, 3);
assert.equal(r.calls[2].dto.legal_name, "Company B");
});
for (const status of ["not_provisioned", "pending", "completed"] as const) {
test(`GET ${status}: regresión de router sin escritura`, async (t) => {
const r = await runtime(t);
r.setStatus(status);
const response = await r.request(undefined, true, "/api/v1/onboarding/initial", "GET");
assert.equal(response.status, 200);
assert.deepEqual(response.body, {
status,
company_id: status === "completed" ? companyId : null,
});
assert.equal(r.calls.length, 0);
assert.equal(r.provisionCalls(), 0);
});
}
test("GET unauthorized", async (t) => {
const r = await runtime(t);
assert.equal(
(await r.request(undefined, false, "/api/v1/onboarding/initial", "GET")).status,
401
);
});
test("POST not provisioned: 409 con código estable sin provisioning", async (t) => {
const r = await runtime(t, new InitialOnboardingNotProvisionedError());
const response = await r.request();
assert.equal(response.status, 409);
assert.deepEqual(response.body, {
type: "https://httpstatuses.com/409",
title: "Conflict",
status: 409,
detail: "Initial onboarding has not been provisioned.",
errors: [
{
code: "initial_onboarding_not_provisioned",
message: "Initial onboarding has not been provisioned.",
},
],
instance: path,
method: "POST",
});
assert.equal(r.calls.length, 1);
assert.equal(r.provisionCalls(), 0);
});
test("POST /initial provisiona explícitamente sin Company context", async (t) => {
const r = await runtime(t);
const response = await r.request({}, true, "/api/v1/onboarding/initial");
assert.equal(response.status, 200);
assert.deepEqual(response.body, { status: "pending", company_id: null });
assert.equal(r.provisionCalls(), 1);
});
test("POST /initial exige autenticación", async (t) => {
const r = await runtime(t);
assert.equal((await r.request({}, false, "/api/v1/onboarding/initial")).status, 401);
assert.equal(r.provisionCalls(), 0);
});

View File

@ -1,12 +1,13 @@
import { type StartParams, requireAuthenticatedGuard, validateRequest } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import type { StartParams } from "@erp/core/api";
import { requireIdentityAuthenticated } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import { CompleteInitialOnboardingRequestSchema } from "../../../common";
import type { OnboardingInternalDeps } from "../di";
import { CompleteInitialOnboardingController } from "./complete-initial-onboarding.controller";
import { GetInitialOnboardingStatusController } from "./get-initial-onboarding-status.controller";
import { ProvisionInitialOnboardingController } from "./provision-initial-onboarding.controller";
import { validateCompleteInitialOnboardingRequest } from "./validate-complete-initial-onboarding-request";
export const onboardingRouter = (params: StartParams) => {
const { app, config, getInternal } = params;
@ -15,23 +16,25 @@ export const onboardingRouter = (params: StartParams) => {
// ----------------------------------------------
router.use(...requireIdentityTenant(params));
router.use(...requireIdentityAuthenticated(params));
router.get(
"/initial",
requireAuthenticatedGuard(),
(req: Request, res: Response, next: NextFunction) => {
router.get("/initial", (req: Request, res: Response, next: NextFunction) => {
const controller = new GetInitialOnboardingStatusController(
deps.useCases.getInitialOnboardingStatus
);
return controller.execute(req, res, next);
}
});
router.post("/initial", (req: Request, res: Response, next: NextFunction) => {
const controller = new ProvisionInitialOnboardingController(
deps.applicationServices.provisioner
);
return controller.execute(req, res, next);
});
router.post(
"/initial/complete",
requireAuthenticatedGuard(),
validateRequest(CompleteInitialOnboardingRequestSchema, "body"),
validateCompleteInitialOnboardingRequest,
(req: Request, res: Response, next: NextFunction) => {
const controller = new CompleteInitialOnboardingController(
deps.useCases.completeInitialOnboarding

View File

@ -0,0 +1,24 @@
import { ExpressController, requireAuthenticatedGuard } from "@erp/core/api";
import type { InitialOnboardingProvisioner } from "../../application/services/initial-onboarding-provisioner";
export class ProvisionInitialOnboardingController extends ExpressController {
public constructor(private readonly provisioner: InitialOnboardingProvisioner) {
super();
this.registerGuards(requireAuthenticatedGuard());
}
protected async executeImpl() {
const user = this.getUser();
if (!user?.userId) return this.unauthorizedError("Unauthorized");
const result = await this.provisioner.provision(user.userId);
return result.match(
(onboarding) =>
this.ok({
status: onboarding.status,
company_id: onboarding.companyId.isSome()
? onboarding.companyId.unwrap().toPrimitive()
: null,
}),
(error) => this.handleError(error)
);
}
}

View File

@ -0,0 +1,27 @@
import { ApiError, ExpressController } from "@erp/core/api";
import type { RequestHandler } from "express";
import { CompleteInitialOnboardingRequestSchema } from "../../../common";
/** Este contrato exige 400 de transporte; ValidationApiError global representa 422. */
export const validateCompleteInitialOnboardingRequest: RequestHandler = (req, res, next) => {
const parsed = CompleteInitialOnboardingRequestSchema.safeParse(req.body);
if (!parsed.success) {
return ExpressController.errorResponse(
new ApiError({
status: 400,
title: "Validation Failed",
detail: "Validation failed",
type: "https://httpstatuses.com/400",
errors: parsed.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
}),
req,
res
);
}
req.body = parsed.data;
next();
};

View File

@ -27,7 +27,7 @@ test("onboarding composition builds public capabilities and registers its model"
} as never);
assert.ok(result?.services?.general);
assert.deepEqual(result.services.general, {});
assert.ok(result.services.general.provisioner);
assert.ok(result.internal);
assert.ok(result.models?.includes(initialOnboardingModelInit));
assert.equal(provisionCalls, 0);

View File

@ -12,7 +12,7 @@ export const onboardingModule: IModuleServer = {
dependencies: ["companies", "identity"],
setup(params) {
const internal = buildOnboardingDependencies(params);
const general: IOnboardingPublicServices = {};
const general: IOnboardingPublicServices = internal.applicationServices;
return { models, services: { general }, internal };
},
};

View File

@ -1,3 +1,4 @@
import { CountryCodeSchema, PostalCodeSchema } from "@erp/core";
import { z } from "zod/v4";
export const CompleteInitialOnboardingRequestSchema = z
@ -13,9 +14,9 @@ export const CompleteInitialOnboardingRequestSchema = z
street: z.string().min(1),
street_2: z.string().nullable().optional(),
city: z.string().min(1),
postal_code: z.string().min(1),
postal_code: PostalCodeSchema,
province: z.string().min(1),
country_code: z.string().length(2),
country_code: CountryCodeSchema,
})
.strict(),
})