Completar columnas en listado de proformas recurrentes

This commit is contained in:
David Arranz 2026-09-02 12:04:05 +02:00
parent f6002d7367
commit 727543f14f
33 changed files with 1131 additions and 209 deletions

View File

@ -1,4 +1,5 @@
import type { DiscountPercentage } from "@erp/core/api";
import type { CustomerPublicSummary } from "@erp/customers/api";
import type { CurrencyCode, LanguageCode, UniqueID } from "@repo/rdx-ddd";
import type {
@ -21,10 +22,13 @@ export type RecurringProformaScheduleReadModel = {
export type RecurringProformaListItemReadModel = {
id: UniqueID;
name: string;
description: string | null;
customerId: UniqueID | null;
customer: CustomerPublicSummary | null;
status: RecurringProformaStatus;
schedule: RecurringProformaScheduleReadModel | null;
nextOccurrenceDate: Date | null;
hasIssue: boolean;
updatedAt: Date;
};
@ -88,8 +92,11 @@ export type ListRecurringProformasQuery = {
companyId: UniqueID;
page: number;
limit: number;
status?: RecurringProformaStatus;
status?: Exclude<RecurringProformaStatus, "archived">;
search?: string;
scope?: "default" | "archived";
orderBy?: "name" | "updated_at";
order?: "asc" | "desc";
};
export type ListRecurringProformaOccurrencesQuery = {

View File

@ -15,7 +15,10 @@ import {
RecurringProformaOccurrence,
} from "../../../domain";
import { toRecurringProformaAuditTarget } from "../audit";
import { RecurringProformaCommands, RecurringProformaReader } from "./recurring-proforma-capabilities";
import {
RecurringProformaCommands,
RecurringProformaReader,
} from "./recurring-proforma-capabilities";
const required = <T>(result: { data: T }): T => result.data;
@ -36,30 +39,49 @@ const buildRecurring = () => {
const currency = required(CurrencyCode.create("EUR"));
const language = required(LanguageCode.create("es"));
const discount = required(DiscountPercentage.create({ value: 500 }));
const schedule = required(RecurrenceSchedule.create({ startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month", interval: 1, monthlyRule: "same_day" }));
return required(RecurringProforma.create({
companyId: companyA,
name: "Cuota mensual",
customerId: Maybe.none(),
description: Maybe.none(),
paymentTermId: Maybe.none(),
paymentMethodId: Maybe.none(),
language: Maybe.none(),
currency: Maybe.none(),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
globalDiscountPercentage: Maybe.some(discount),
schedule: Maybe.some(schedule),
items: [{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
unitAmount: Maybe.some(required(ItemAmount.create({ value: 1000, currency_code: currency.code }))),
itemDiscountPercentage: Maybe.some(discount),
ivaCode: Maybe.some("general"), recCode: Maybe.some("rec"), retentionCode: Maybe.some("ret"),
}],
createdAt: new Date("2027-01-01T00:00:00.000Z"),
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
}, recurringId));
const schedule = required(
RecurrenceSchedule.create({
startDate: new Date("2027-01-15T00:00:00.000Z"),
endDate: null,
unit: "month",
interval: 1,
monthlyRule: "same_day",
})
);
return required(
RecurringProforma.create(
{
companyId: companyA,
name: "Cuota mensual",
customerId: Maybe.none(),
description: Maybe.none(),
paymentTermId: Maybe.none(),
paymentMethodId: Maybe.none(),
language: Maybe.none(),
currency: Maybe.none(),
proformaSeries: Maybe.none(),
issuedInvoiceSeries: Maybe.none(),
globalDiscountPercentage: Maybe.some(discount),
schedule: Maybe.some(schedule),
items: [
{
description: Maybe.some(required(ItemDescription.create("Servicio"))),
quantity: Maybe.some(required(ItemQuantity.create({ value: 2 }))),
unitAmount: Maybe.some(
required(ItemAmount.create({ value: 1000, currency_code: currency.code }))
),
itemDiscountPercentage: Maybe.some(discount),
ivaCode: Maybe.some("general"),
recCode: Maybe.some("rec"),
retentionCode: Maybe.some("ret"),
},
],
createdAt: new Date("2027-01-01T00:00:00.000Z"),
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
},
recurringId
)
);
};
test("toRecurringProformaAuditTarget incluye el nombre como label", () => {
@ -72,31 +94,72 @@ test("toRecurringProformaAuditTarget incluye el nombre como label", () => {
test("RecurringProformaReader propaga company, filtros, paginación y conserva el listado", async () => {
let receivedQuery: unknown;
let receivedTransaction: unknown;
const item = { id: recurringId, name: "Cuota mensual", customerId: null, status: "draft" as const, schedule: null, nextOccurrenceDate: null, updatedAt: new Date("2027-01-02T00:00:00.000Z") };
const reader = new RecurringProformaReader({
listInCompany: async (query, currentTransaction) => {
receivedQuery = query; receivedTransaction = currentTransaction;
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
},
} as never, {} as never, transactionManager());
const result = await reader.list({ companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" });
const item = {
id: recurringId,
name: "Cuota mensual",
description: null,
customerId: null,
customer: null,
status: "draft" as const,
schedule: null,
nextOccurrenceDate: null,
hasIssue: false,
updatedAt: new Date("2027-01-02T00:00:00.000Z"),
};
const reader = new RecurringProformaReader(
{
listInCompany: async (query, currentTransaction) => {
receivedQuery = query;
receivedTransaction = currentTransaction;
return Result.ok({ page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
},
} as never,
{} as never,
{} as never,
transactionManager()
);
const result = await reader.list({
companyId: companyA,
page: 2,
limit: 5,
status: "draft",
search: "Cuota",
});
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, page: 2, limit: 5, status: "draft", search: "Cuota" });
assert.deepEqual(receivedQuery, {
companyId: companyA,
page: 2,
limit: 5,
status: "draft",
search: "Cuota",
});
assert.equal(receivedTransaction, transaction);
assert.deepEqual(result.data, { page: 2, perPage: 5, totalItems: 6, totalPages: 2, items: [item] });
assert.deepEqual(result.data, {
page: 2,
perPage: 5,
totalItems: 6,
totalPages: 2,
items: [item],
});
assert.equal((receivedQuery as { companyId: UniqueID }).companyId.equals(companyB), false);
});
test("RecurringProformaReader conserva overrides nulos, schedule e items ordenados en detail", async () => {
let receivedCompany: UniqueID | undefined;
const recurring = buildRecurring();
const reader = new RecurringProformaReader({
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
receivedCompany = companyId;
assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction);
return Result.ok(recurring);
},
} as never, {} as never, transactionManager());
const reader = new RecurringProformaReader(
{
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
receivedCompany = companyId;
assert.equal(id.equals(recurringId), true);
assert.equal(currentTransaction, transaction);
return Result.ok(recurring);
},
} as never,
{} as never,
{} as never,
transactionManager()
);
const result = await reader.detail({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
assert.equal(receivedCompany?.equals(companyA), true);
@ -114,15 +177,92 @@ test("RecurringProformaReader conserva overrides nulos, schedule e items ordenad
test("RecurringProformaReader propaga parent, empresa y paginación de occurrences", async () => {
let receivedQuery: unknown;
const proformaId = UniqueID.generateNewID();
const generated = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-02-15T00:00:00.000Z"), status: "generated", attemptCount: 1, proformaId: Maybe.some(proformaId), generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")), createdAt: new Date(), updatedAt: new Date() }));
const failed = required(RecurringProformaOccurrence.create({ companyId: companyA, recurringProformaId: recurringId, scheduledDate: new Date("2027-01-15T00:00:00.000Z"), status: "failed", attemptCount: 2, lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")), lastErrorCode: Maybe.some("failed"), lastErrorMessage: Maybe.some("Error"), createdAt: new Date(), updatedAt: new Date() }));
const toRead = (source: typeof generated) => ({ id: source.id, scheduledDate: source.scheduledDate, status: source.status, attemptCount: source.attemptCount, lastAttemptAt: source.lastAttemptAt.match((value) => value, () => null), processingStartedAt: null, lastErrorCode: source.lastErrorCode.match((value) => value, () => null), lastErrorMessage: source.lastErrorMessage.match((value) => value, () => null), proformaId: source.proformaId.match((value) => value, () => null), generatedAt: source.generatedAt.match((value) => value, () => null), createdAt: source.createdAt, updatedAt: source.updatedAt });
const reader = new RecurringProformaReader({} as never, {
listByRecurringProformaInCompany: async (query) => { receivedQuery = query; return Result.ok({ page: 1, perPage: 10, totalItems: 2, totalPages: 1, items: [toRead(generated), toRead(failed)] }); },
} as never, transactionManager());
const result = await reader.occurrences({ companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 });
const generated = required(
RecurringProformaOccurrence.create({
companyId: companyA,
recurringProformaId: recurringId,
scheduledDate: new Date("2027-02-15T00:00:00.000Z"),
status: "generated",
attemptCount: 1,
proformaId: Maybe.some(proformaId),
generatedAt: Maybe.some(new Date("2027-02-16T00:00:00.000Z")),
createdAt: new Date(),
updatedAt: new Date(),
})
);
const failed = required(
RecurringProformaOccurrence.create({
companyId: companyA,
recurringProformaId: recurringId,
scheduledDate: new Date("2027-01-15T00:00:00.000Z"),
status: "failed",
attemptCount: 2,
lastAttemptAt: Maybe.some(new Date("2027-01-16T00:00:00.000Z")),
lastErrorCode: Maybe.some("failed"),
lastErrorMessage: Maybe.some("Error"),
createdAt: new Date(),
updatedAt: new Date(),
})
);
const toRead = (source: typeof generated) => ({
id: source.id,
scheduledDate: source.scheduledDate,
status: source.status,
attemptCount: source.attemptCount,
lastAttemptAt: source.lastAttemptAt.match(
(value) => value,
() => null
),
processingStartedAt: null,
lastErrorCode: source.lastErrorCode.match(
(value) => value,
() => null
),
lastErrorMessage: source.lastErrorMessage.match(
(value) => value,
() => null
),
proformaId: source.proformaId.match(
(value) => value,
() => null
),
generatedAt: source.generatedAt.match(
(value) => value,
() => null
),
createdAt: source.createdAt,
updatedAt: source.updatedAt,
});
const reader = new RecurringProformaReader(
{} as never,
{
listByRecurringProformaInCompany: async (query) => {
receivedQuery = query;
return Result.ok({
page: 1,
perPage: 10,
totalItems: 2,
totalPages: 1,
items: [toRead(generated), toRead(failed)],
});
},
} as never,
{} as never,
transactionManager()
);
const result = await reader.occurrences({
companyId: companyA,
recurringProformaId: recurringId,
page: 1,
limit: 10,
});
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedQuery, { companyId: companyA, recurringProformaId: recurringId, page: 1, limit: 10 });
assert.deepEqual(receivedQuery, {
companyId: companyA,
recurringProformaId: recurringId,
page: 1,
limit: 10,
});
assert.equal(result.data.items[0]?.proformaId?.equals(proformaId), true);
assert.equal(result.data.items[1]?.lastErrorCode, "failed");
assert.equal(result.data.items[1]?.attemptCount, 2);
@ -131,29 +271,73 @@ test("RecurringProformaReader propaga parent, empresa y paginación de occurrenc
test("RecurringProformaCommands posee la transacción y propaga las transiciones", async () => {
const calls: Array<{ action: string; transaction: unknown }> = [];
const commands = new RecurringProformaCommands({
statusChanger: { change: async (params: { action: string; transaction: unknown; companyId: UniqueID; id: UniqueID }) => {
assert.equal(params.companyId.equals(companyA), true); assert.equal(params.id.equals(recurringId), true);
calls.push({ action: params.action, transaction: params.transaction }); return Result.ok(buildRecurring());
} },
creator: {}, updater: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {},
statusChanger: {
change: async (params: {
action: string;
transaction: unknown;
companyId: UniqueID;
id: UniqueID;
}) => {
assert.equal(params.companyId.equals(companyA), true);
assert.equal(params.id.equals(recurringId), true);
calls.push({ action: params.action, transaction: params.transaction });
return Result.ok(buildRecurring());
},
},
creator: {},
updater: {},
deleter: {},
duplicator: {},
fromProformaCreator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(),
} as never);
for (const action of ["activate", "pause", "resume", "complete", "archive"] as const) {
const result = await commands[action]({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);
}
assert.deepEqual(calls.map((call) => call.action), ["activate", "pause", "resume", "complete", "archive"]);
assert.equal(calls.every((call) => call.transaction === transaction), true);
assert.deepEqual(
calls.map((call) => call.action),
["activate", "pause", "resume", "complete", "archive"]
);
assert.equal(
calls.every((call) => call.transaction === transaction),
true
);
});
test("RecurringProformaCommands propaga name y schedule al facade fromProforma", async () => {
let received: { name: string; schedule: unknown; transaction: unknown } | undefined;
const schedule = { startDate: new Date("2027-01-15T00:00:00.000Z"), endDate: null, unit: "month" as const, interval: 1, monthlyRule: "same_day" as const };
const schedule = {
startDate: new Date("2027-01-15T00:00:00.000Z"),
endDate: null,
unit: "month" as const,
interval: 1,
monthlyRule: "same_day" as const,
};
const commands = new RecurringProformaCommands({
fromProformaCreator: { create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => { received = params; return Result.ok(buildRecurring()); } },
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
fromProformaCreator: {
create: async (params: { name: string; schedule: typeof schedule; transaction: unknown }) => {
received = params;
return Result.ok(buildRecurring());
},
},
creator: {},
updater: {},
statusChanger: {},
deleter: {},
duplicator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(),
} as never);
const result = await commands.fromProforma({ companyId: companyA, proformaId: recurringId, name: "Desde proforma", schedule });
const result = await commands.fromProforma({
companyId: companyA,
proformaId: recurringId,
name: "Desde proforma",
schedule,
});
assert.equal(result.isSuccess, true);
assert.equal(received?.name, "Desde proforma");
assert.deepEqual(received?.schedule, schedule);
@ -164,24 +348,96 @@ test("RecurringProformaCommands propaga create, update, duplicate y delete sin n
const calls: Record<string, unknown> = {};
const patch = { paymentTermId: null };
const commands = new RecurringProformaCommands({
creator: { create: async (params: { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown }) => { calls.create = params; return Result.ok(buildRecurring()); } },
updater: { update: async (params: { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown }) => { calls.update = params; return Result.ok(buildRecurring()); } },
duplicator: { duplicate: async (params: { companyId: UniqueID; sourceId: UniqueID; transaction: unknown }) => { calls.duplicate = params; return Result.ok(buildRecurring()); } },
deleter: { delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => { calls.delete = params; return Result.ok(true); } },
statusChanger: {}, fromProformaCreator: {}, readinessChecker: {}, repository: {}, transactionManager: transactionManager(),
creator: {
create: async (params: {
companyId: UniqueID;
name: string;
description?: Maybe<string>;
transaction: unknown;
}) => {
calls.create = params;
return Result.ok(buildRecurring());
},
},
updater: {
update: async (params: {
companyId: UniqueID;
id: UniqueID;
patch: typeof patch;
transaction: unknown;
}) => {
calls.update = params;
return Result.ok(buildRecurring());
},
},
duplicator: {
duplicate: async (params: {
companyId: UniqueID;
sourceId: UniqueID;
transaction: unknown;
}) => {
calls.duplicate = params;
return Result.ok(buildRecurring());
},
},
deleter: {
delete: async (params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) => {
calls.delete = params;
return Result.ok(true);
},
},
statusChanger: {},
fromProformaCreator: {},
readinessChecker: {},
repository: {},
transactionManager: transactionManager(),
} as never);
assert.equal((await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" })).isSuccess, true);
assert.equal((await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess, true);
assert.equal((await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess, true);
assert.equal(
(await commands.create({ companyId: companyA, name: "Nueva", description: "Descripción" }))
.isSuccess,
true
);
assert.equal(
(await commands.update({ companyId: companyA, id: recurringId, patch })).isSuccess,
true
);
assert.equal(
(await commands.duplicate({ companyId: companyA, sourceId: recurringId })).isSuccess,
true
);
assert.equal((await commands.delete({ companyId: companyA, id: recurringId })).data, true);
const create = calls.create as { companyId: UniqueID; name: string; description?: Maybe<string>; transaction: unknown };
const update = calls.update as { companyId: UniqueID; id: UniqueID; patch: typeof patch; transaction: unknown };
const duplicate = calls.duplicate as { companyId: UniqueID; sourceId: UniqueID; transaction: unknown };
const create = calls.create as {
companyId: UniqueID;
name: string;
description?: Maybe<string>;
transaction: unknown;
};
const update = calls.update as {
companyId: UniqueID;
id: UniqueID;
patch: typeof patch;
transaction: unknown;
};
const duplicate = calls.duplicate as {
companyId: UniqueID;
sourceId: UniqueID;
transaction: unknown;
};
const deleted = calls.delete as { companyId: UniqueID; id: UniqueID; transaction: unknown };
assert.equal(create.companyId.equals(companyA), true); assert.equal(create.name, "Nueva"); assert.equal(create.description?.unwrap(), "Descripción"); assert.equal(create.transaction, transaction);
assert.equal(update.companyId.equals(companyA), true); assert.equal(update.id.equals(recurringId), true); assert.equal(update.patch.paymentTermId, null); assert.equal(update.transaction, transaction);
assert.equal(duplicate.companyId.equals(companyA), true); assert.equal(duplicate.sourceId.equals(recurringId), true); assert.equal(duplicate.transaction, transaction);
assert.equal(deleted.companyId.equals(companyA), true); assert.equal(deleted.id.equals(recurringId), true); assert.equal(deleted.transaction, transaction);
assert.equal(create.companyId.equals(companyA), true);
assert.equal(create.name, "Nueva");
assert.equal(create.description?.unwrap(), "Descripción");
assert.equal(create.transaction, transaction);
assert.equal(update.companyId.equals(companyA), true);
assert.equal(update.id.equals(recurringId), true);
assert.equal(update.patch.paymentTermId, null);
assert.equal(update.transaction, transaction);
assert.equal(duplicate.companyId.equals(companyA), true);
assert.equal(duplicate.sourceId.equals(recurringId), true);
assert.equal(duplicate.transaction, transaction);
assert.equal(deleted.companyId.equals(companyA), true);
assert.equal(deleted.id.equals(recurringId), true);
assert.equal(deleted.transaction, transaction);
});
test("RecurringProformaCommands propaga readiness con blockers sin interpretarlos", async () => {
@ -190,9 +446,35 @@ test("RecurringProformaCommands propaga readiness con blockers sin interpretarlo
const readiness = { canActivate: false, blockers: [{ code: "missing_customer" as const }] };
const recurring = buildRecurring();
const commands = new RecurringProformaCommands({
repository: { findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => { repositoryCalls += 1; assert.equal(companyId.equals(companyA), true); assert.equal(id.equals(recurringId), true); assert.equal(currentTransaction, transaction); return Result.ok(recurring); } },
readinessChecker: { check: async (params: { companyId: UniqueID; recurringProforma: RecurringProforma; transaction: unknown }) => { checkerCalls += 1; assert.equal(params.companyId.equals(companyA), true); assert.equal(params.recurringProforma, recurring); assert.equal(params.transaction, transaction); return Result.ok(readiness); } },
creator: {}, updater: {}, statusChanger: {}, deleter: {}, duplicator: {}, fromProformaCreator: {}, transactionManager: transactionManager(),
repository: {
findByIdInCompany: async (companyId: UniqueID, id: UniqueID, currentTransaction: unknown) => {
repositoryCalls += 1;
assert.equal(companyId.equals(companyA), true);
assert.equal(id.equals(recurringId), true);
assert.equal(currentTransaction, transaction);
return Result.ok(recurring);
},
},
readinessChecker: {
check: async (params: {
companyId: UniqueID;
recurringProforma: RecurringProforma;
transaction: unknown;
}) => {
checkerCalls += 1;
assert.equal(params.companyId.equals(companyA), true);
assert.equal(params.recurringProforma, recurring);
assert.equal(params.transaction, transaction);
return Result.ok(readiness);
},
},
creator: {},
updater: {},
statusChanger: {},
deleter: {},
duplicator: {},
fromProformaCreator: {},
transactionManager: transactionManager(),
} as never);
const result = await commands.activationReadiness({ companyId: companyA, id: recurringId });
assert.equal(result.isSuccess, true);

View File

@ -1,4 +1,5 @@
import type { ITransactionManager } from "@erp/core/api";
import type { ICustomerPublicServices } from "@erp/customers/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
@ -34,13 +35,43 @@ export class RecurringProformaReader {
public constructor(
private readonly repository: IRecurringProformaRepository,
private readonly occurrenceRepository: IRecurringProformaOccurrenceRepository,
private readonly customerServices: ICustomerPublicServices,
private readonly transactionManager: ITransactionManager
) {}
public list(query: ListRecurringProformasQuery) {
return this.transactionManager.complete((transaction) =>
this.repository.listInCompany(query, transaction)
);
return this.transactionManager.complete(async (transaction) => {
const page = await this.repository.listInCompany(query, transaction);
if (page.isFailure) return Result.fail(page.error);
const customerIds = [
...new Map(
page.data.items
.flatMap((item) => (item.customerId ? [item.customerId] : []))
.map((customerId) => [customerId.toPrimitive(), customerId])
).values(),
];
if (customerIds.length === 0) return page;
const customers = await this.customerServices.findSummariesByIds(customerIds, {
companyId: query.companyId,
transaction,
});
if (customers.isFailure) return Result.fail(customers.error);
const customersById = new Map(
customers.data.map((customer) => [customer.id.toPrimitive(), customer])
);
return Result.ok({
...page.data,
items: page.data.items.map((item) => ({
...item,
customer: item.customerId
? (customersById.get(item.customerId.toPrimitive()) ?? null)
: null,
})),
});
});
}
public detail(params: { companyId: UniqueID; id: UniqueID }) {

View File

@ -16,6 +16,18 @@ import {
type ListProformasUseCase,
type PreviewProformaReportUseCase,
ProformaIssuanceReadinessChecker,
ProformaTaxConfigResolver,
RecurringProformaActivationReadinessChecker,
RecurringProformaCommands,
RecurringProformaCreator,
RecurringProformaDeleter,
RecurringProformaDuplicator,
RecurringProformaFromProformaCreator,
RecurringProformaMaterializer,
type RecurringProformaOccurrenceProcessor,
RecurringProformaReader,
RecurringProformaStatusChanger,
RecurringProformaUpdater,
type ReportProformaPdfUseCase,
type UnarchiveProformaByIdUseCase,
type UpdateProformaByIdUseCase,
@ -47,23 +59,15 @@ import {
buildUnarchiveProformaByIdUseCase,
buildUpdateProformaUseCase,
createRecurringProformaOccurrenceProcessor,
ProformaTaxConfigResolver,
RecurringProformaMaterializer,
RecurringProformaActivationReadinessChecker,
RecurringProformaCommands,
RecurringProformaCreator,
RecurringProformaDeleter,
RecurringProformaDuplicator,
RecurringProformaFromProformaCreator,
type RecurringProformaOccurrenceProcessor,
RecurringProformaReader,
RecurringProformaStatusChanger,
RecurringProformaUpdater,
} from "../../../application";
import {
CompanyVerifactuProfileDomainMapper,
SequelizeCompanyVerifactuProfileRepository,
} from "../../company-verifactu-profile/persistence";
import {
SequelizeRecurringProformaOccurrenceRepository,
SequelizeRecurringProformaRepository,
} from "../../recurring-proformas/persistence/sequelize/repositories";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { DocumentSeriesProformaSeriesValidator } from "../services/document-series-proforma-series-validator";
import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/document-series-proforma-target-invoice-series-validator";
@ -71,10 +75,6 @@ import { DocumentSeriesProformaTargetInvoiceSeriesValidator } from "../services/
import { buildProformaDocumentServices } from "./proforma-documents.di";
import { buildV2ProformaPersistence } from "./proforma-persistence.di";
import { resolveProformaCatalogsDeps } from "./proforrma-catalog-deps.di";
import {
SequelizeRecurringProformaOccurrenceRepository,
SequelizeRecurringProformaRepository,
} from "../../recurring-proformas/persistence/sequelize/repositories";
export type ProformasInternalDeps = {
recurringProformas: {
@ -218,6 +218,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const recurringReader = new RecurringProformaReader(
recurringProformaRepository,
occurrenceRepository,
customerServices,
transactionManager
);
const recurringCommands = new RecurringProformaCommands({
@ -425,7 +426,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager,
}),
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
issueProforma: (publicServices: { issuedInvoiceServices: IIssuedInvoicePublicServices }) =>
buildIssueProformaUseCase({
publicServices,
finder,

View File

@ -69,14 +69,15 @@ abstract class RecurringProformaController extends ExpressController {
export class ListRecurringProformasController extends RecurringProformaController {
public constructor(
private readonly list: any,
private readonly presenter = new RecurringProformaListPresenter()
private readonly presenter = new RecurringProformaListPresenter(),
private readonly scope: "default" | "archived" = "default"
) {
super();
}
protected async executeImpl() {
const companyId = this.company();
if (!companyId) return this.forbiddenError("Tenant ID not found");
const result = await this.list({ companyId, ...this.req.query });
const result = await this.list({ companyId, ...this.req.query, scope: this.scope });
return result.match(
(data: any) => this.ok(this.presenter.present(data)),
(error: Error) => this.handleError(error)

View File

@ -12,7 +12,14 @@ export class RecurringProformaListPresenter {
items: page.items.map((item: any) => ({
id: id(item.id),
name: item.name,
customer_id: id(item.customerId),
description: item.description,
customer: item.customer
? {
id: id(item.customer.id),
name: item.customer.name,
tin: item.customer.tin,
}
: null,
status: item.status,
schedule: item.schedule
? {
@ -24,6 +31,7 @@ export class RecurringProformaListPresenter {
}
: null,
next_occurrence_date: dateOnly(item.nextOccurrenceDate),
has_issue: item.hasIssue,
updated_at: item.updatedAt.toISOString(),
})),
};

View File

@ -25,7 +25,13 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () =
{
id: UniqueID.generateNewID(),
name: "Mantenimiento",
description: "Servicio preventivo",
customerId: UniqueID.generateNewID(),
customer: {
id: UniqueID.generateNewID(),
name: "ACME",
tin: "B12345678",
},
status: "active",
schedule: {
startDate: new Date("2027-02-03T00:00:00.000Z"),
@ -35,12 +41,16 @@ test("HTTP list presenta paginación vacía y fila con schedule date-only", () =
monthlyRule: "same_day",
},
nextOccurrenceDate: new Date("2027-03-03T00:00:00.000Z"),
hasIssue: true,
updatedAt: new Date("2027-02-01T10:00:00.000Z"),
},
],
});
assert.equal(dto.items[0]?.schedule.start_date, "2027-02-03");
assert.equal(dto.items[0]?.next_occurrence_date, "2027-03-03");
assert.equal(dto.items[0]?.description, "Servicio preventivo");
assert.equal(dto.items[0]?.customer?.name, "ACME");
assert.equal(dto.items[0]?.has_issue, true);
});
test("HTTP detail conserva null inheritance, overrides, orden y date-only", () => {

View File

@ -40,6 +40,12 @@ export const recurringProformasRouter = (params: StartParams) => {
(req: Request, res: Response, next: NextFunction) =>
new ListRecurringProformasController(deps.list).execute(req, res, next)
);
router.get(
"/archived",
validateRequest(ListRecurringProformasRequestSchema, "query"),
(req: Request, res: Response, next: NextFunction) =>
new ListRecurringProformasController(deps.list, undefined, "archived").execute(req, res, next)
);
router.post(
"/",
validateRequest(CreateRecurringProformaRequestSchema, "body"),

View File

@ -52,7 +52,10 @@ test("SequelizeRecurringProformaRepository reemplaza items en la transacción su
);
const originalUpsert = RecurringProformaModel.upsert;
const originalDestroy = RecurringProformaItemModel.destroy;
RecurringProformaModel.upsert = (async () => [{} as never, true]) as typeof RecurringProformaModel.upsert;
RecurringProformaModel.upsert = (async () => [
{} as never,
true,
]) as typeof RecurringProformaModel.upsert;
RecurringProformaItemModel.destroy = (async () => 0) as typeof RecurringProformaItemModel.destroy;
try {
assert.equal((await repository.save(recurringProforma, {})).isSuccess, true);
@ -76,10 +79,17 @@ test("findByIdInCompanyForUpdate exige transacción y limita por company", async
return null;
}) as typeof RecurringProformaModel.findOne;
try {
assert.equal((await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction)).isFailure, true);
assert.equal(
(await repository.findByIdInCompanyForUpdate(companyId, recurringProformaId, transaction))
.isFailure,
true
);
assert.equal(options?.lock, true);
assert.equal(options?.transaction, transaction);
assert.deepEqual(options?.where, { id: recurringProformaId.toPrimitive(), company_id: companyId.toPrimitive() });
assert.deepEqual(options?.where, {
id: recurringProformaId.toPrimitive(),
company_id: companyId.toPrimitive(),
});
} finally {
RecurringProformaModel.findOne = originalFindOne;
}
@ -97,13 +107,58 @@ test("listInCompany limita tenant, filtros, paginación y orden determinista", a
return { count: 0, rows: [] } as never;
}) as typeof RecurringProformaModel.findAndCountAll;
try {
const result = await repository.listInCompany({ companyId, status: "draft", search: "Cuota", page: 2, limit: 10 });
const result = await repository.listInCompany({
companyId,
status: "draft",
search: "Cuota",
page: 2,
limit: 10,
});
assert.equal(result.isSuccess, true);
assert.deepEqual(options?.where, { company_id: companyId.toPrimitive(), status: "draft", name: { [Op.like]: "%Cuota%" } });
assert.deepEqual(options?.order, [["updated_at", "DESC"], ["id", "DESC"]]);
assert.deepEqual(options?.where, {
company_id: companyId.toPrimitive(),
status: "draft",
name: { [Op.like]: "%Cuota%" },
});
assert.deepEqual(options?.order, [
["updated_at", "DESC"],
["id", "DESC"],
]);
assert.equal(options?.limit, 10);
assert.equal(options?.offset, 10);
} finally {
RecurringProformaModel.findAndCountAll = originalFindAndCountAll;
}
});
test("listInCompany excluye archivadas y permite ordenar por nombre", async () => {
const repository = new SequelizeRecurringProformaRepository(
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const originalFindAndCountAll = RecurringProformaModel.findAndCountAll;
let options: { where?: Record<string, unknown>; order?: unknown } | undefined;
RecurringProformaModel.findAndCountAll = (async (value) => {
options = value;
return { count: 0, rows: [] } as never;
}) as typeof RecurringProformaModel.findAndCountAll;
try {
await repository.listInCompany({
companyId,
page: 1,
limit: 20,
orderBy: "name",
order: "asc",
});
assert.deepEqual(options?.where, {
company_id: companyId.toPrimitive(),
status: { [Op.ne]: "archived" },
});
assert.deepEqual(options?.order, [
["name", "ASC"],
["id", "DESC"],
]);
} finally {
RecurringProformaModel.findAndCountAll = originalFindAndCountAll;
}
});

View File

@ -1,7 +1,7 @@
import { EntityNotFoundError, translateSequelizeError } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Op, type Transaction } from "sequelize";
import { literal, Op, type Transaction } from "sequelize";
import type {
DueRecurringProforma,
@ -166,7 +166,11 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
try {
const where = {
company_id: query.companyId.toPrimitive(),
...(query.status ? { status: query.status } : {}),
...(query.scope === "archived"
? { status: "archived" }
: query.status
? { status: query.status }
: { status: { [Op.ne]: "archived" } }),
...(query.search ? { name: { [Op.like]: `%${query.search}%` } } : {}),
};
const limit = Math.max(1, query.limit);
@ -175,6 +179,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
attributes: [
"id",
"name",
"description",
"customer_id",
"status",
"schedule_start_date",
@ -184,10 +189,20 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
"schedule_monthly_rule",
"next_occurrence_date",
"updated_at",
[
literal(`EXISTS (
SELECT 1
FROM recurring_proforma_occurrences AS occurrence
WHERE occurrence.recurring_proforma_id = RecurringProformaModel.id
AND occurrence.company_id = RecurringProformaModel.company_id
AND occurrence.status = 'failed'
)`),
"has_issue",
],
],
where,
order: [
["updated_at", "DESC"],
[query.orderBy ?? "updated_at", (query.order ?? "desc").toUpperCase()],
["id", "DESC"],
],
limit,
@ -204,7 +219,9 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
items.push({
id: id.data,
name: row.name,
description: row.description ?? null,
customerId: customerId?.data ?? null,
customer: null,
status: row.status as RecurringProformaListItemReadModel["status"],
schedule: hasSchedule
? {
@ -222,6 +239,7 @@ export class SequelizeRecurringProformaRepository implements IRecurringProformaR
nextOccurrenceDate: row.next_occurrence_date
? this.toDateOnly(row.next_occurrence_date)
: null,
hasIssue: Boolean(row.get("has_issue")),
updatedAt: row.updated_at,
});
}

View File

@ -3,9 +3,11 @@ import { z } from "zod/v4";
export const RecurringProformaIdParamsSchema = z.object({ recurring_proforma_id: z.uuid() });
export const ListRecurringProformasRequestSchema = z.object({
search: z.string().min(1).optional(),
status: z.enum(["draft", "active", "paused", "completed", "archived"]).optional(),
status: z.enum(["draft", "active", "paused", "completed"]).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
orderBy: z.enum(["name", "updated_at"]).optional(),
order: z.enum(["asc", "desc"]).optional(),
});
export const ListRecurringProformaOccurrencesRequestSchema = z.object({
page: z.coerce.number().int().positive().default(1),

View File

@ -31,9 +31,18 @@
"empty": "There are no recurring proformas.",
"columns": {
"name": "Name",
"customer": "Customer",
"status": "Status",
"schedule": "Recurrence",
"updated_at": "Updated"
"frequency": "Frequency",
"next_occurrence": "Next generation",
"issues": "Issues",
"updated_at": "Last updated",
"actions": "Actions"
},
"issues": {
"none": "No issues",
"issue": "Issue"
},
"schedule": {
"none": "No recurrence",
@ -52,6 +61,15 @@
"retry": "Retry"
}
},
"actions": {
"activate": "Activate",
"pause": "Pause",
"resume": "Resume",
"complete": "Complete",
"archive": "Archive",
"duplicate": "Duplicate",
"delete": "Delete"
},
"status": {
"all": "All",
"draft": "Draft",

View File

@ -31,9 +31,18 @@
"empty": "No hay proformas recurrentes.",
"columns": {
"name": "Nombre",
"customer": "Cliente",
"status": "Estado",
"schedule": "Recurrencia",
"updated_at": "Actualizada"
"frequency": "Frecuencia",
"next_occurrence": "Próxima generación",
"issues": "Incidencias",
"updated_at": "Última actualización",
"actions": "Acciones"
},
"issues": {
"none": "Sin incidencias",
"issue": "Incidencia"
},
"schedule": {
"none": "Sin recurrencia",
@ -52,6 +61,15 @@
"retry": "Reintentar"
}
},
"actions": {
"activate": "Activar",
"pause": "Pausar",
"resume": "Reanudar",
"complete": "Completar",
"archive": "Archivar",
"duplicate": "Duplicar",
"delete": "Eliminar"
},
"status": {
"all": "Todos",
"draft": "Borrador",

View File

@ -145,6 +145,18 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</RecurringProformaLayout>
),
},
{
path: "recurring-proformas/archived",
handle: {
layout: "app-sidebar",
protected: true,
},
element: (
<RecurringProformaLayout>
<RecurringProformasListPage />
</RecurringProformaLayout>
),
},
{
path: "recurring-proformas/create",
handle: {

View File

@ -1,6 +1,7 @@
import { useDebounce } from "@erp/core/hooks";
import type { DataTableSort, DataTableSortDirection } from "@repo/rdx-ui/components";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useRecurringProformas } from "../../shared/hooks/use-recurring-proformas";
@ -11,8 +12,10 @@ import type {
} from "../../shared/recurring-proforma-list";
import { buildRecurringProformaDetailPath } from "../../shared/recurring-proforma-navigation";
const STATUSES = ["draft", "active", "paused", "completed", "archived"] as const;
const STATUSES = ["draft", "active", "paused", "completed"] as const;
const PAGE_SIZE = 20;
const DEFAULT_SORT = { field: "updatedAt", direction: "desc" } satisfies DataTableSort;
const SORT_FIELDS = { name: "name", updatedAt: "updated_at" } as const;
type RecurringProformaStatusFilter = "all" | RecurringProformaStatus;
@ -23,9 +26,9 @@ interface RecurringProformaPageErrorViewModel {
const EMPTY_RECURRING_PROFORMAS_LIST: RecurringProformaList = {
page: 1,
per_page: PAGE_SIZE,
total_items: 0,
total_pages: 0,
perPage: PAGE_SIZE,
totalItems: 0,
totalPages: 0,
items: [],
};
@ -38,6 +41,7 @@ const resolveStatusFilter = (value: string | null): RecurringProformaStatusFilte
export const useListRecurringProformasPageController = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [statusFilter, setStatusFilter] = useState<RecurringProformaStatusFilter>(
@ -47,6 +51,15 @@ export const useListRecurringProformasPageController = () => {
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const limit = Math.max(1, Number(searchParams.get("limit") ?? String(PAGE_SIZE)) || PAGE_SIZE);
const debouncedSearch = useDebounce(search, 300);
const sortField = searchParams.get("sortField");
const sortDirection = searchParams.get("sortDirection");
const sort =
sortField !== null &&
sortField in SORT_FIELDS &&
(sortDirection === "asc" || sortDirection === "desc")
? { field: sortField, direction: sortDirection as DataTableSortDirection }
: DEFAULT_SORT;
const scope = location.pathname.endsWith("/archived") ? "archived" : "default";
useEffect(() => {
setSearch(searchParams.get("search") ?? "");
@ -59,8 +72,11 @@ export const useListRecurringProformasPageController = () => {
limit,
...(debouncedSearch ? { search: debouncedSearch } : {}),
...(statusFilter === "all" ? {} : { status: statusFilter }),
orderBy: SORT_FIELDS[sort.field as keyof typeof SORT_FIELDS] ?? SORT_FIELDS.updatedAt,
order: sort.direction,
scope,
}),
[debouncedSearch, limit, page, statusFilter]
[debouncedSearch, limit, page, scope, sort.direction, sort.field, statusFilter]
);
const query = useRecurringProformas(filters);
@ -134,6 +150,15 @@ export const useListRecurringProformasPageController = () => {
[limit, updateSearchParams]
);
const setSort = useCallback(
(nextSort: DataTableSort) => {
const field = nextSort.field in SORT_FIELDS ? nextSort.field : DEFAULT_SORT.field;
const direction = nextSort.direction === "asc" ? "asc" : "desc";
updateSearchParams({ sortField: field, sortDirection: direction });
},
[updateSearchParams]
);
const retryList = useCallback(async () => {
await query.refetch();
}, [query]);
@ -192,6 +217,9 @@ export const useListRecurringProformasPageController = () => {
statusFilter,
setStatusFilter: setStatusFilterValue,
status: statusFilter === "all" ? undefined : statusFilter,
sort,
setSort,
scope,
},
pageError,
refreshError,

View File

@ -14,9 +14,9 @@ interface ControllerState {
listCtrl: {
data: {
page: number;
per_page: number;
total_items: number;
total_pages: number;
perPage: number;
totalItems: number;
totalPages: number;
items: Array<Record<string, unknown>>;
};
isLoading: boolean;
@ -29,6 +29,8 @@ interface ControllerState {
setPageSize: (value: number) => void;
statusFilter: string;
setStatusFilter: (value: string) => void;
sort: { field: string; direction: "asc" | "desc" };
setSort: (value: { field: string; direction: "asc" | "desc" }) => void;
};
pageError: { title: string; description: string } | null;
refreshError: { title: string; message: string } | null;
@ -72,9 +74,9 @@ const createControllerState = (): ControllerState => ({
listCtrl: {
data: {
page: 1,
per_page: 20,
total_items: 0,
total_pages: 0,
perPage: 20,
totalItems: 0,
totalPages: 0,
items: [],
},
isLoading: false,
@ -95,6 +97,8 @@ const createControllerState = (): ControllerState => ({
setStatusFilter: (value) => {
events.status = value;
},
sort: { field: "updatedAt", direction: "desc" },
setSort: () => undefined,
},
pageError: null,
refreshError: null,
@ -167,6 +171,12 @@ mock.module("@repo/shadcn-ui/components", {
return <button type="button">{children}</button>;
},
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
<button type="button">{children}</button>
),
DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
ToggleGroup: ({ children, onValueChange }: ToggleGroupProps & { children: ReactNode }) => {
capturedToggleGroupProps = { onValueChange };
return <div>{children}</div>;
@ -177,6 +187,12 @@ mock.module("@repo/shadcn-ui/components", {
},
});
mock.module("../shared/hooks/use-recurring-proforma-action-mutation", {
namedExports: {
useRecurringProformaActionMutation: () => ({ isPending: false, mutate: () => undefined }),
},
});
const renderPage = async () => {
const { ListRecurringProformasPage } = await import("./list-recurring-proformas-page");
@ -230,24 +246,26 @@ test("conecta CTA, detalle, búsqueda, filtro y paginación con el controller",
controllerState = createControllerState();
controllerState.listCtrl.data = {
page: 1,
per_page: 20,
total_items: 1,
total_pages: 1,
perPage: 20,
totalItems: 1,
totalPages: 1,
items: [
{
id: "rec-1",
name: "Cuota mensual",
customer_id: null,
description: null,
customer: null,
status: "active",
schedule: {
start_date: "2026-09-01",
end_date: null,
startDate: "2026-09-01",
endDate: null,
unit: "month",
interval: 1,
monthly_rule: "same_day",
monthlyRule: "same_day",
},
next_occurrence_date: null,
updated_at: "2026-09-01",
nextOccurrenceDate: null,
hasIssue: false,
updatedAt: "2026-09-01",
},
],
};

View File

@ -1,24 +1,53 @@
import { PageErrorState, SimpleSearchInput } from "@erp/core/components";
import { DataTable, DataTableColumnHeader, RdxPageContainer } from "@repo/rdx-ui/components";
import { Badge, Button } from "@repo/shadcn-ui/components";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { CircleIcon, MoreHorizontalIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "../../i18n";
import type { RecurringProformaListItem } from "../shared/recurring-proforma-list";
import type { RecurringProformaListRow } from "../shared/recurring-proforma-list";
import { buildRecurringProformaDetailPath } from "../shared/recurring-proforma-navigation";
import {
type RecurringProformaAction,
useRecurringProformaActionMutation,
} from "../shared/hooks/use-recurring-proforma-action-mutation";
import { useListRecurringProformasPageController } from "./controllers/use-list-recurring-proformas-page-controller";
import {
formatRecurringProformaSchedule,
formatRecurringProformaDateOnly,
formatRecurringProformaDateTime,
getRecurringProformaStatusKey,
} from "./recurring-proforma-list-utils";
import { RecurringProformaStatusSegmentedFilter } from "./ui/components/recurring-proforma-status-segmented-filter";
import { ScopedListRecurringProformasPageView } from "./ui/pages/scoped-list-recurring-proformas-page-view";
const getActions = (status: RecurringProformaListRow["status"]): RecurringProformaAction[] => {
switch (status) {
case "draft":
return ["activate", "complete", "duplicate", "delete"];
case "active":
return ["pause", "complete", "duplicate"];
case "paused":
return ["resume", "complete", "duplicate"];
case "completed":
return ["archive", "duplicate"];
case "archived":
return ["duplicate"];
}
};
export const ListRecurringProformasPage = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [isSearchPending, setIsSearchPending] = useState(false);
const {
listCtrl,
@ -30,12 +59,14 @@ export const ListRecurringProformasPage = () => {
openCreatePage,
openDetailPage,
} = useListRecurringProformasPageController();
const actionMutation = useRecurringProformaActionMutation();
const dateLocale = i18n.language === "en" ? "en-US" : "es-ES";
const columns = useMemo<ColumnDef<RecurringProformaListItem>[]>(
const columns = useMemo<ColumnDef<RecurringProformaListRow>[]>(
() => [
{
accessorKey: "name",
enableSorting: false,
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
@ -47,10 +78,35 @@ export const ListRecurringProformasPage = () => {
className="font-medium text-primary underline-offset-4 hover:underline"
to={buildRecurringProformaDetailPath(row.original.id)}
>
{row.original.name}
<span className="block font-semibold">{row.original.name}</span>
{row.original.description ? (
<span className="mt-0.5 block text-sm font-normal text-muted-foreground">
{row.original.description}
</span>
) : null}
</Link>
),
},
{
id: "customer",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.customer")}
/>
),
cell: ({ row }) =>
row.original.customer ? (
<div>
<span className="block font-medium">{row.original.customer.name}</span>
<span className="block text-sm text-muted-foreground">
{row.original.customer.tin ?? "—"}
</span>
</div>
) : (
"—"
),
},
{
accessorKey: "status",
enableSorting: false,
@ -65,12 +121,12 @@ export const ListRecurringProformasPage = () => {
),
},
{
id: "schedule",
id: "frequency",
enableSorting: false,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.schedule")}
title={t("recurring_proformas.list.columns.frequency")}
/>
),
cell: ({ row }) =>
@ -79,18 +135,98 @@ export const ListRecurringProformasPage = () => {
),
},
{
accessorKey: "updated_at",
enableSorting: false,
id: "nextOccurrenceDate",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.next_occurrence")}
/>
),
cell: ({ row }) =>
formatRecurringProformaDateOnly(row.original.nextOccurrenceDate, dateLocale),
},
{
id: "hasIssue",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.issues")}
/>
),
cell: ({ row }) => (
<span className="flex items-center gap-2">
<CircleIcon
aria-hidden="true"
className={
row.original.hasIssue
? "size-2 fill-red-500 text-red-500"
: "size-2 fill-emerald-500 text-emerald-500"
}
/>
{t(
row.original.hasIssue
? "recurring_proformas.list.issues.issue"
: "recurring_proformas.list.issues.none"
)}
</span>
),
},
{
accessorKey: "updatedAt",
enableSorting: true,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.updated_at")}
/>
),
cell: ({ row }) => <span className="tabular-nums">{row.original.updated_at}</span>,
cell: ({ row }) => (
<span className="tabular-nums">
{formatRecurringProformaDateTime(row.original.updatedAt, dateLocale)}
</span>
),
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={t("recurring_proformas.list.columns.actions")}
/>
),
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={t("recurring_proformas.list.columns.actions")}
size="icon"
type="button"
variant="ghost"
>
<MoreHorizontalIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
{getActions(row.original.status).map((action) => (
<DropdownMenuItem
disabled={actionMutation.isPending}
key={action}
onClick={(event) => {
event.stopPropagation();
actionMutation.mutate({ id: row.original.id, action });
}}
>
{t(`recurring_proformas.actions.${action}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[t]
[actionMutation, dateLocale, t]
);
if (hasBlockingError && pageError) {
@ -123,12 +259,15 @@ export const ListRecurringProformasPage = () => {
isFetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
manualPagination
manualSorting
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(row) => openDetailPage(row.id)}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
searching={isSearchPending}
sort={listCtrl.sort}
topContent={
<div className="space-y-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
@ -150,10 +289,10 @@ export const ListRecurringProformasPage = () => {
/>
</div>
}
totalItems={listCtrl.data.total_items}
totalItems={listCtrl.data.totalItems}
/>
{listCtrl.data.total_items === 0 ? (
{listCtrl.data.totalItems === 0 ? (
<div className="rounded border border-dashed border-border bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
{t("recurring_proformas.list.empty")}
</div>

View File

@ -2,34 +2,53 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
formatRecurringProformaDateOnly,
formatRecurringProformaSchedule,
getRecurringProformaStatusKey,
getRecurringProformaListFeedbackState,
} from "./recurring-proforma-list-utils";
test("resuelve loading, error, empty y rows para el listado", () => {
assert.equal(getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }), "loading");
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }), "error");
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }), "empty");
assert.equal(getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }), "ready");
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: true, isError: false, totalItems: 0 }),
"loading"
);
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: true, totalItems: 0 }),
"error"
);
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 0 }),
"empty"
);
assert.equal(
getRecurringProformaListFeedbackState({ isLoading: false, isError: false, totalItems: 1 }),
"ready"
);
});
test("presenta la recurrencia mensual de una fila sin calcular ocurrencias", () => {
const result = formatRecurringProformaSchedule(
{
start_date: "2026-09-01",
end_date: null,
startDate: "2026-09-01",
endDate: null,
unit: "month",
interval: 1,
monthly_rule: "same_day",
monthlyRule: "same_day",
},
(key) => ({
"recurring_proformas.list.schedule.month": "Cada mes",
"recurring_proformas.list.schedule.same_day": "Mismo dia",
})[key] ?? key
(key) =>
({
"recurring_proformas.list.schedule.month": "Cada mes",
"recurring_proformas.list.schedule.same_day": "Mismo dia",
})[key] ?? key
);
assert.equal(result, "Cada mes, Mismo dia");
assert.equal(result, "Cada mes · Mismo dia");
});
test("presenta fechas civiles sin desplazar el día y usa un valor vacío consistente", () => {
assert.equal(formatRecurringProformaDateOnly("2026-09-01", "es-ES"), "1/9/26");
assert.equal(formatRecurringProformaDateOnly(null, "es-ES"), "—");
});
test("resuelve la clave i18n del estado que presenta cada fila", () => {

View File

@ -1,5 +1,6 @@
import type {
RecurringProformaListItem,
RecurringProformaDetail,
RecurringProformaListRow,
RecurringProformaStatus,
} from "../shared/recurring-proforma-list";
@ -9,7 +10,7 @@ export const getRecurringProformaStatusKey = (status: RecurringProformaStatus) =
`recurring_proformas.status.${status}`;
export const formatRecurringProformaSchedule = (
schedule: RecurringProformaListItem["schedule"],
schedule: RecurringProformaListRow["schedule"] | RecurringProformaDetail["schedule"],
t: Translate
) => {
if (!schedule) return t("recurring_proformas.list.schedule.none");
@ -17,14 +18,27 @@ export const formatRecurringProformaSchedule = (
const cadence = t(`recurring_proformas.list.schedule.${schedule.unit}`, {
count: schedule.interval,
});
const monthlyRule = "monthlyRule" in schedule ? schedule.monthlyRule : schedule.monthly_rule;
const rule =
schedule.monthly_rule === null
? ""
: `, ${t(`recurring_proformas.list.schedule.${schedule.monthly_rule}`)}`;
monthlyRule === null ? "" : ` · ${t(`recurring_proformas.list.schedule.${monthlyRule}`)}`;
return `${cadence}${rule}`;
};
export const formatRecurringProformaDateTime = (value: string, locale: string) =>
new Intl.DateTimeFormat(locale, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
export const formatRecurringProformaDateOnly = (value: string | null, locale: string) => {
if (!value) return "—";
const [year, month, day] = value.split("-").map(Number);
return new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(
new Date(year, month - 1, day)
);
};
export const getRecurringProformaListFeedbackState = ({
isLoading,
isError,

View File

@ -1,12 +1,6 @@
import { ToggleGroup, ToggleGroupItem } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ArchiveIcon,
CheckCircle2Icon,
CirclePauseIcon,
FileTextIcon,
PlayCircleIcon,
} from "lucide-react";
import { CheckCircle2Icon, CirclePauseIcon, FileTextIcon, PlayCircleIcon } from "lucide-react";
import type { ElementType } from "react";
import { useTranslation } from "../../../../i18n";
@ -56,13 +50,6 @@ const RECURRING_PROFORMA_STATUS_FILTER_OPTIONS = [
activeClassName:
"data-[state=on]:border-sky-300 data-[state=on]:bg-sky-50 data-[state=on]:text-sky-700",
},
{
value: "archived",
labelKey: "recurring_proformas.status.archived",
icon: ArchiveIcon,
activeClassName:
"data-[state=on]:border-muted-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground",
},
] as const satisfies readonly {
value: RecurringProformaStatusFilter;
labelKey: string;

View File

@ -0,0 +1,35 @@
import type {
RecurringProformaList,
RecurringProformaListDTO,
RecurringProformaListRow,
} from "../recurring-proforma-list";
const toRow = (dto: RecurringProformaListDTO["items"][number]): RecurringProformaListRow => ({
id: dto.id,
name: dto.name,
description: dto.description,
customer: dto.customer,
status: dto.status,
schedule: dto.schedule
? {
startDate: dto.schedule.start_date,
endDate: dto.schedule.end_date,
unit: dto.schedule.unit,
interval: dto.schedule.interval,
monthlyRule: dto.schedule.monthly_rule,
}
: null,
nextOccurrenceDate: dto.next_occurrence_date,
hasIssue: dto.has_issue,
updatedAt: dto.updated_at,
});
export const ListRecurringProformasAdapter = {
fromDto: (dto: RecurringProformaListDTO): RecurringProformaList => ({
page: dto.page,
perPage: dto.per_page,
totalItems: dto.total_items,
totalPages: dto.total_pages,
items: dto.items.map(toRow),
}),
};

View File

@ -1,13 +1,21 @@
import type { IDataSource } from "@erp/core/client";
import type { RecurringProformaList, RecurringProformaListFilters } from "../recurring-proforma-list";
import type {
RecurringProformaListDTO,
RecurringProformaListFilters,
} from "../recurring-proforma-list";
export const listRecurringProformas = (
dataSource: IDataSource,
filters: RecurringProformaListFilters,
signal?: AbortSignal
): Promise<RecurringProformaList> =>
dataSource.getList<RecurringProformaList>("recurring-proformas", {
...filters,
signal,
});
): Promise<RecurringProformaListDTO> => {
const { scope = "default", ...query } = filters;
return dataSource.getList<RecurringProformaListDTO>(
scope === "archived" ? "recurring-proformas/archived" : "recurring-proformas",
{
...query,
signal,
}
);
};

View File

@ -0,0 +1,31 @@
import { useDataSource } from "@erp/core/hooks";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
export type RecurringProformaAction =
| "activate"
| "pause"
| "resume"
| "complete"
| "archive"
| "duplicate"
| "delete";
export const useRecurringProformaActionMutation = () => {
const dataSource = useDataSource();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, action }: { id: string; action: RecurringProformaAction }) =>
dataSource.custom({
path:
action === "delete" ? `recurring-proformas/${id}` : `recurring-proformas/${id}/${action}`,
method: action === "delete" ? "delete" : "post",
data: undefined,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: recurringProformaQueryKeys.lists() });
},
});
};

View File

@ -2,6 +2,7 @@ import { useDataSource } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query";
import { listRecurringProformas } from "../api/list-recurring-proformas.api";
import { ListRecurringProformasAdapter } from "../adapters/list-recurring-proformas.adapter";
import type { RecurringProformaListFilters } from "../recurring-proforma-list";
import { recurringProformaQueryKeys } from "./recurring-proforma-query-keys";
@ -11,7 +12,10 @@ export const useRecurringProformas = (filters: RecurringProformaListFilters) =>
return useQuery({
queryKey: recurringProformaQueryKeys.list(filters),
queryFn: ({ signal }) => listRecurringProformas(dataSource, filters, signal),
queryFn: async ({ signal }) =>
ListRecurringProformasAdapter.fromDto(
await listRecurringProformas(dataSource, filters, signal)
),
placeholderData: (previousData) => previousData,
});
};

View File

@ -1,21 +1,24 @@
export type RecurringProformaStatus =
| "draft"
| "active"
| "paused"
| "completed"
| "archived";
export type RecurringProformaStatus = "draft" | "active" | "paused" | "completed" | "archived";
export type RecurringProformaListFilters = {
page: number;
limit: number;
search?: string;
status?: RecurringProformaStatus;
orderBy?: "name" | "updated_at";
order?: "asc" | "desc";
scope?: "default" | "archived";
};
export type RecurringProformaListItem = {
export type RecurringProformaListItemDTO = {
id: string;
name: string;
customer_id: string | null;
description: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
status: RecurringProformaStatus;
schedule: {
start_date: string;
@ -25,15 +28,46 @@ export type RecurringProformaListItem = {
monthly_rule: "same_day" | "last_day_of_month" | null;
} | null;
next_occurrence_date: string | null;
has_issue: boolean;
updated_at: string;
};
export type RecurringProformaList = {
export type RecurringProformaListDTO = {
page: number;
per_page: number;
total_items: number;
total_pages: number;
items: RecurringProformaListItem[];
items: RecurringProformaListItemDTO[];
};
export type RecurringProformaListRow = {
id: string;
name: string;
description: string | null;
customer: {
id: string;
name: string;
tin: string | null;
} | null;
status: RecurringProformaStatus;
schedule: {
startDate: string;
endDate: string | null;
unit: "week" | "month" | "year";
interval: number;
monthlyRule: "same_day" | "last_day_of_month" | null;
} | null;
nextOccurrenceDate: string | null;
hasIssue: boolean;
updatedAt: string;
};
export type RecurringProformaList = {
page: number;
perPage: number;
totalItems: number;
totalPages: number;
items: RecurringProformaListRow[];
};
export type RecurringProformaPercentage = {
@ -60,18 +94,14 @@ export type RecurringProformaDetail = {
proforma_series: string | null;
issued_invoice_series: string | null;
global_discount_percentage: RecurringProformaPercentage | null;
schedule: RecurringProformaListItem["schedule"];
schedule: RecurringProformaListItemDTO["schedule"];
next_occurrence_date: string | null;
items: RecurringProformaDetailItem[];
created_at: string;
updated_at: string;
};
export type RecurringProformaOccurrenceStatus =
| "pending"
| "processing"
| "failed"
| "generated";
export type RecurringProformaOccurrenceStatus = "pending" | "processing" | "failed" | "generated";
export type RecurringProformaOccurrencesFilters = {
page: number;

View File

@ -0,0 +1,8 @@
import type { UniqueID } from "@repo/rdx-ddd";
/** Proyección mínima de cliente para consumidores de otros módulos. */
export type CustomerPublicSummary = {
id: UniqueID;
name: string;
tin: string | null;
};

View File

@ -1,5 +1,6 @@
export * from "./customer-full-read-model";
export * from "./customer-payment-method-read.model";
export * from "./customer-payment-term-read.model";
export * from "./customer-public-summary";
export * from "./customer-summary";
export * from "./customer-tax-regime-full-read.model";

View File

@ -3,13 +3,19 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Collection, Result } from "@repo/rdx-utils";
import type { Customer } from "../../domain/aggregates";
import type { CustomerSummary } from "../models";
import type { CustomerPublicSummary, CustomerSummary } from "../models";
/**
* Interfaz del repositorio para el agregado `Customer`.
* El escopado multitenant está representado por `companyId`.
*/
export interface ICustomerRepository {
findPublicSummariesByIdsInCompany(
companyId: UniqueID,
ids: readonly UniqueID[],
transaction?: unknown
): Promise<Result<readonly CustomerPublicSummary[], Error>>;
/**
*
* Crea un nuevo cliente

View File

@ -2,6 +2,7 @@ import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Customer, ICustomerCreateProps } from "../../domain";
import type { CustomerPublicSummary } from "../models";
export interface ICustomerServicesContext {
transaction: unknown;
@ -9,6 +10,11 @@ export interface ICustomerServicesContext {
}
export interface ICustomerPublicServices {
findSummariesByIds: (
customerIds: readonly UniqueID[],
context: ICustomerServicesContext
) => Promise<Result<readonly CustomerPublicSummary[], Error>>;
findCustomerByTIN: (
tin: TINNumber,
context: ICustomerServicesContext

View File

@ -30,6 +30,14 @@ export function buildCustomerPublicServices(
const creator = buildCustomerCreator({ repository, audit: params.audit });
return {
findSummariesByIds: async (customerIds, context) => {
return repository.findPublicSummariesByIdsInCompany(
context.companyId,
customerIds,
context.transaction
);
},
findCustomerByTIN: async (tin: TINNumber, context: ICustomerServicesContext) => {
const { companyId, transaction } = context;

View File

@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Op, Sequelize } from "sequelize";
import { CustomerModel } from "../models/sequelize-customer.model";
import { CustomerRepository } from "./customer.repository";
const required = <T>(result: { data: T }): T => result.data;
test("findPublicSummariesByIdsInCompany proyecta clientes únicos y mantiene el scope", async () => {
const repository = new CustomerRepository(
{} as never,
{} as never,
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const customerId = required(UniqueID.create("01900000-0000-7000-8000-000000000502"));
const originalFindAll = CustomerModel.findAll;
let received: { attributes?: unknown; where?: Record<string, unknown> } | undefined;
CustomerModel.findAll = (async (options) => {
received = options;
return [{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }] as never;
}) as typeof CustomerModel.findAll;
try {
const result = await repository.findPublicSummariesByIdsInCompany(companyId, [
customerId,
customerId,
]);
assert.ok(result.isSuccess, result.isFailure ? result.error.message : "");
assert.deepEqual(received?.attributes, ["id", "name", "tin"]);
assert.deepEqual(received?.where, {
company_id: companyId.toPrimitive(),
id: { [Op.in]: [customerId.toPrimitive()] },
});
assert.deepEqual(
result.data.map((customer) => ({ ...customer, id: customer.id.toPrimitive() })),
[{ id: customerId.toPrimitive(), name: "ACME", tin: "B12345678" }]
);
} finally {
CustomerModel.findAll = originalFindAll;
}
});
test("findPublicSummariesByIdsInCompany no consulta cuando no hay IDs", async () => {
const repository = new CustomerRepository(
{} as never,
{} as never,
new Sequelize("mysql://root:pass@localhost:3306/erp_test", { logging: false })
);
const companyId = required(UniqueID.create("01900000-0000-7000-8000-000000000501"));
const originalFindAll = CustomerModel.findAll;
let calls = 0;
CustomerModel.findAll = (async () => {
calls += 1;
return [];
}) as typeof CustomerModel.findAll;
try {
const result = await repository.findPublicSummariesByIdsInCompany(companyId, []);
assert.equal(result.isSuccess, true);
assert.deepEqual(result.data, []);
assert.equal(calls, 0);
} finally {
CustomerModel.findAll = originalFindAll;
}
});

View File

@ -1,10 +1,22 @@
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { UniqueID } from "@repo/rdx-ddd";
import type { TINNumber } from "@repo/rdx-ddd";
import { type Collection, Result } from "@repo/rdx-utils";
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
import {
Op,
type FindOptions,
type InferAttributes,
type OrderItem,
type Sequelize,
type Transaction,
} from "sequelize";
import type { CustomerSummary, ICustomerRepository } from "../../../../application";
import type {
CustomerPublicSummary,
CustomerSummary,
ICustomerRepository,
} from "../../../../application";
import type { Customer } from "../../../../domain";
import type { SequelizeCustomerDomainMapper, SequelizeCustomerSummaryMapper } from "../mappers";
import { CustomerModel } from "../models/sequelize-customer.model";
@ -21,6 +33,35 @@ export class CustomerRepository
super({ database });
}
public async findPublicSummariesByIdsInCompany(
companyId: UniqueID,
ids: readonly UniqueID[],
transaction?: unknown
): Promise<Result<readonly CustomerPublicSummary[], Error>> {
try {
const uniqueIds = [...new Set(ids.map((id) => id.toPrimitive()))];
if (uniqueIds.length === 0) return Result.ok([]);
const rows = await CustomerModel.findAll({
attributes: ["id", "name", "tin"],
where: {
company_id: companyId.toPrimitive(),
id: { [Op.in]: uniqueIds },
},
transaction: transaction as Transaction | undefined,
});
const summaries: CustomerPublicSummary[] = [];
for (const row of rows) {
const id = UniqueID.create(row.id);
if (id.isFailure) return Result.fail(id.error);
summaries.push({ id: id.data, name: row.name, tin: row.tin ?? null });
}
return Result.ok(summaries);
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
*
* Crea un nuevo cliente

View File

@ -4,7 +4,7 @@ import type { ICustomerPublicServices } from "./application";
import { buildCustomerPublicServices, buildCustomersDependencies } from "./infrastructure/di";
import { models } from "./infrastructure/persistence/sequelize";
export type { ICustomerPublicServices } from "./application";
export type { CustomerPublicSummary, ICustomerPublicServices } from "./application";
export * from "./infrastructure/persistence/sequelize";
/** Manifest neutral de clientes: composición y servicio público sin HTTP. */