diff --git a/docs/catalogs/payment-methods-design.md b/docs/catalogs/payment-methods-design.md index a5824d9e..487aa9ff 100644 --- a/docs/catalogs/payment-methods-design.md +++ b/docs/catalogs/payment-methods-design.md @@ -98,6 +98,26 @@ Consumidores actuales auditados: `factuges` no bloquea delete porque resuelve métodos de pago durante la importación y no mantiene una referencia persistente viva auditada en este flujo. +## Emision de Proformas + +`customer-invoices` consume `PaymentMethod` mediante servicios publicos de `catalogs`. + +Semantica vigente en emision: + +- la Proforma mantiene una referencia viva `paymentMethodId`; +- la Issued Invoice persiste un snapshot historico del metodo de pago emitido; +- si `paymentMethodId` no existe dentro de la company activa, la emision falla; +- si el metodo existe pero esta inactivo, la emision falla; +- este bloqueo aplica solo al boundary de emision y no convierte automaticamente `paymentMethodId` en obligatorio durante create draft. + +Datos copiados al snapshot historico de `issued_invoices`: + +- `payment_method_id` +- `payment_method_name` +- `payment_method_description` + +Tras emitir, el detalle historico no depende del catalogo vivo para representar la forma de pago usada. + ## Frontend Settings La gestión administrativa frontend vive en: diff --git a/docs/customer-invoices/README.md b/docs/customer-invoices/README.md index cf81ab79..75f1762a 100644 --- a/docs/customer-invoices/README.md +++ b/docs/customer-invoices/README.md @@ -10,6 +10,7 @@ - [Diseño backend del perfil VeriFactu empresarial](./settings/verifactu-company-profile-backend-design.md) - [Contrato de series de proformas](./proforma-series-contract.md) - [Contrato de create de proformas](./proforma-create-contract.md) +- [Contrato de readiness de emisión de proformas](./proforma-issuance-readiness-contract.md) - [Configuración fiscal persistida en proformas](./proforma-tax-config.md) - [Contrato de listado de proformas](./proforma-list-contract.md) - [Contrato de archivado de proformas](./proforma-archive-contract.md) diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 4194d998..0dc14e7a 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -109,6 +109,30 @@ Adaptando nombres exactos al contrato publico actual: - al existir lineas valoradas, `update` recalcula `taxes` y `totals` - `issue` genera la `issued_invoice` materializada usando `target_invoice_series_code` si existe y, si no, la default activa de `issued_invoice` +## Diferencia entre draft y emision + +`create` y `update` no deben imponer automaticamente todas las precondiciones de emision. + +Una Proforma puede existir validamente como borrador incompleto. + +En concreto: + +- `proforma_date` forma parte de la estructura base del documento y debe existir desde create; +- `operation_date` no se exige en create ni en update general; +- `description` no se exige en create ni en update general; +- `payment_method_id` puede seguir vacio mientras el documento permanezca en draft. + +La obligatoriedad de esos campos pertenece al boundary de `issue`. + +Precondiciones funcionales de emision auditadas en backend: + +- `description` obligatoria; +- `operation_date` obligatoria; +- `payment_method_id` obligatorio y resoluble dentro de la company activa; +- el `payment_method` debe seguir activo al emitir; +- `payment_term_id` sigue siendo opcional; +- la emision sigue usando `invoice_date = today` como fecha documental de la factura emitida. + ## Relacion con borrado - una proforma creada en `draft` puede borrarse logicamente mas adelante diff --git a/docs/customer-invoices/proforma-issuance-readiness-contract.md b/docs/customer-invoices/proforma-issuance-readiness-contract.md new file mode 100644 index 00000000..3edb9e05 --- /dev/null +++ b/docs/customer-invoices/proforma-issuance-readiness-contract.md @@ -0,0 +1,100 @@ +# Contrato de readiness de emisión de proformas + +## Objetivo + +La readiness de emisión es una lectura informativa del estado persistido de una proforma. + +Su propósito es responder: + +```txt +¿Puede emitirse esta proforma ahora mismo? +``` + +No sustituye la validación transaccional final de: + +```txt +PUT /proformas/:id/issue +``` + +La emisión sigue revalidando siempre en backend para cubrir concurrencia y cambios entre consulta y submit. + +## Endpoint vigente + +```http +GET /proformas/:proforma_id/issuance-readiness +``` + +Comportamiento: + +```txt +- 200 cuando la proforma existe dentro del scope de company +- 200 aunque can_issue sea false +- blockers con códigos estables orientados a frontend +- sin textos i18n en backend +``` + +No se expone readiness completa en el listado de proformas para evitar N+1 HTTP y resolución externa por fila. + +## Contrato HTTP + +```json +{ + "can_issue": false, + "blockers": [ + { "code": "missing_operation_date" }, + { "code": "missing_description" } + ] +} +``` + +## Códigos públicos + +| Code | Significado | +| ---- | ----------- | +| `already_issued` | La proforma ya tiene emisión materializada o enlazada. | +| `invalid_status` | El estado actual no permite emitir; hoy debe estar aprobada. | +| `missing_description` | Falta la descripción obligatoria para emitir. | +| `missing_operation_date` | Falta la fecha de operación. | +| `incomplete_recipient` | Faltan datos fiscales mínimos del destinatario. | +| `missing_items` | No existen líneas de detalle persistidas. | +| `missing_payment_method` | Falta la forma de pago. | +| `missing_tax_regime` | Falta el régimen fiscal. | +| `payment_method_unavailable` | La referencia company-scoped de forma de pago no se puede resolver. | +| `payment_method_inactive` | La forma de pago resuelta existe, pero está inactiva. | + +## Fuente de verdad + +La semántica se reparte de forma explícita pero sin duplicar reglas: + +```txt +Proforma.getIssuanceBlockers() + -> blockers internos de dominio + +Proforma.validateCanBeIssued() + -> consume el primer blocker compatible con el contrato legacy de error + +ProformaIssuanceReadinessChecker + -> añade blockers externos dependientes de catálogos +``` + +Esto permite: + +```txt +readiness +-> enumerar blockers útiles + +issue +-> seguir fallando de forma autoritativa +``` + +## Semántica importante en frontend + +- La readiness representa el estado guardado en backend. +- Los cambios no guardados del editor no alteran este read model. +- Tras guardar, cambiar estado o emitir, la query de readiness debe invalidarse. + +## Pendientes fuera de esta fase + +- exponer readiness en listado sin introducir N+1; +- migrar la ruta legacy de emisión de `PUT` a un contrato nuevo si se aprueba; +- deep-link o focus directo al campo bloqueado desde frontend. diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts new file mode 100644 index 00000000..083c0cb3 --- /dev/null +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { UniqueID, type UtcDate as UtcDateType } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { CompanyVerifactuProfileStatus } from "../../../domain"; + +import { ProformaToIssuedInvoiceConverter } from "./proforma-to-issued-invoice-props-converter"; + +test("toCreateProps normaliza verifactu effectiveAt a YYYY-MM-DD antes de crear UtcDate", () => { + const converter = new ProformaToIssuedInvoiceConverter(); + + Object.assign(converter as object, { + resolveRecipient: () => Result.ok({} as never), + resolveItems: () => Result.ok([]), + resolveTaxes: () => Result.ok([]), + resolvePayment: () => Result.ok({} as never), + resolvePaymentTerm: () => Result.ok(Maybe.none()), + resolveTaxRegime: () => Result.ok({} as never), + resolveFiscalClassification: () => Result.ok({} as never), + }); + + const result = converter.toCreateProps({ + proforma: { + companyId: UniqueID.generateNewID(), + id: UniqueID.generateNewID(), + targetInvoiceSeriesCode: Maybe.none(), + operationDate: Maybe.none(), + description: { + unwrap: () => "Servicio mensual", + }, + languageCode: {} as never, + currencyCode: {} as never, + notes: Maybe.none(), + reference: Maybe.none(), + customerId: UniqueID.generateNewID(), + totals: () => ({ + subtotalAmount: {} as never, + itemsDiscountAmount: {} as never, + globalDiscountAmount: {} as never, + totalDiscountAmount: {} as never, + taxableAmount: {} as never, + ivaAmount: {} as never, + recAmount: {} as never, + retentionAmount: {} as never, + taxesAmount: {} as never, + totalAmount: {} as never, + }), + globalDiscountPercentage: DiscountPercentage.create({ value: 0 }).data, + } as never, + paymentMethod: { + id: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + }, + paymentTerm: Maybe.none(), + taxRegime: { + code: "01", + description: "Regimen general", + }, + fiscalClassification: { + operationQualificationCode: null, + exemptionReasonCode: null, + }, + verifactuPolicy: { + profileStatus: CompanyVerifactuProfileStatus.ObligatedWithVerifactu, + usesVerifactu: true, + effectiveAt: new Date("2026-08-20T08:30:00.000Z"), + }, + }); + + assert.equal(result.isSuccess, true); + const effectiveAt = result.data.verifactuPolicy.unwrap().effectiveAt.unwrap() as UtcDateType; + assert.equal(effectiveAt.toPrimitive(), "2026-08-20"); +}); diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index a1271b21..46bed6b3 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -104,7 +104,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic invoiceDate: UtcDate.today(), operationDate: proforma.operationDate, - description: proforma.description.getOrUndefined()!, + description: proforma.description.unwrap(), languageCode: proforma.languageCode, currencyCode: proforma.currencyCode, @@ -369,7 +369,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic if (source.verifactuPolicy.effectiveAt != null) { const effectiveAtResult = UtcDate.createFromISO( - source.verifactuPolicy.effectiveAt.toISOString() + source.verifactuPolicy.effectiveAt.toISOString().slice(0, 10) ); if (effectiveAtResult.isFailure) { diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts index a391fa12..3b081b37 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-snapshot-builders.di.ts @@ -3,6 +3,7 @@ import { IssueProformaResponseSnapshotBuilder, ProformaFullSnapshotBuilder, + ProformaIssuanceReadinessSnapshotBuilder, ProformaItemReportSnapshotBuilder, ProformaItemsFullSnapshotBuilder, ProformaRecipientFullSnapshotBuilder, @@ -34,10 +35,12 @@ export function buildProformaSnapshotBuilders() { taxesReportBuilder ); const issueResponseSnapshotBuilder = new IssueProformaResponseSnapshotBuilder(); + const issuanceReadinessSnapshotBuilder = new ProformaIssuanceReadinessSnapshotBuilder(); return { full: fullSnapshotBuilder, issue: issueResponseSnapshotBuilder, + issuanceReadiness: issuanceReadinessSnapshotBuilder, summary: summarySnapshotBuilder, report: reportSnapshotBuilder, }; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index 434bbf9f..c8b18e61 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -13,6 +13,7 @@ import type { IProformaDeleter, IProformaFinder, IProformaFullReadModelAssembler, + IProformaIssuanceReadinessChecker, IProformaIssueReadModelAssembler, IProformaIssuer, IProformaStatusChanger, @@ -22,6 +23,7 @@ import type { import type { IIssueProformaResponseSnapshotBuilder, IProformaFullSnapshotBuilder, + IProformaIssuanceReadinessSnapshotBuilder, IProformaReportSnapshotBuilder, IProformaSummarySnapshotBuilder, } from "../snapshot-builders"; @@ -32,6 +34,7 @@ import { DeleteProformaByIdUseCase, DuplicateProformaByIdUseCase, GetProformaByIdUseCase, + GetProformaIssuanceReadinessUseCase, IssueProformaUseCase, ListProformasUseCase, PreviewProformaReportUseCase, @@ -54,6 +57,20 @@ export function buildGetProformaByIdUseCase(deps: { }); } +export function buildGetProformaIssuanceReadinessUseCase(deps: { + finder: IProformaFinder; + readinessChecker: IProformaIssuanceReadinessChecker; + snapshotBuilder: IProformaIssuanceReadinessSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new GetProformaIssuanceReadinessUseCase({ + finder: deps.finder, + readinessChecker: deps.readinessChecker, + snapshotBuilder: deps.snapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + export function buildListProformasUseCase(deps: { finder: IProformaFinder; summarySnapshotBuilder: IProformaSummarySnapshotBuilder; diff --git a/modules/customer-invoices/src/api/application/proformas/models/index.ts b/modules/customer-invoices/src/api/application/proformas/models/index.ts index 7d82ffa5..60011624 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/index.ts @@ -1,6 +1,7 @@ export * from "./company-report-profile.model"; export * from "./proforma-create-input.model"; export * from "./proforma-full-read.model"; +export * from "./proforma-issuance-readiness.model"; export * from "./proforma-item-tax-codes-input.model"; export * from "./proforma-status-counts"; export * from "./proforma-summary"; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts new file mode 100644 index 00000000..a6aa2b48 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-issuance-readiness.model.ts @@ -0,0 +1,8 @@ +import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common"; + +export interface ProformaIssuanceReadinessModel { + canIssue: boolean; + blockers: Array<{ + code: GetProformaIssuanceReadinessResponseDTO["blockers"][number]["code"]; + }>; +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts index 52ddf523..ec16a0a6 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.test.ts @@ -7,6 +7,14 @@ import { Maybe, Result } from "@repo/rdx-utils"; import { ProformaIssueReadModelAssembler } from "./proforma-issue-read-model.assembler"; const buildAssembler = (overrides?: { + paymentMethodResult?: Result<{ + id: UniqueID; + companyId: UniqueID; + name: string; + description: Maybe; + isActive: boolean; + isDefault: boolean; + }, Error>; paymentTermResult?: Maybe<{ id: UniqueID; companyId: UniqueID; @@ -28,10 +36,14 @@ const buildAssembler = (overrides?: { const assembler = new ProformaIssueReadModelAssembler({ paymentMethodFinder: { getByIdInCompany: async () => + overrides?.paymentMethodResult ?? Result.ok({ - id: paymentMethodId.toString(), + id: paymentMethodId, + companyId: UniqueID.generateNewID(), name: "Transferencia", - description: null, + description: Maybe.none(), + isActive: true, + isDefault: false, }), } as never, paymentTermFinder: { @@ -143,3 +155,41 @@ test("assemble rechaza una referencia inconsistente de payment term", async () = assert.equal(result.isFailure, true); assert.match(result.error.message, /inconsistent/i); }); + +test("assemble rechaza emitir con payment method inactivo", async () => { + const paymentMethodId = UniqueID.generateNewID(); + const { assembler } = buildAssembler({ + paymentMethodResult: Result.ok({ + id: paymentMethodId, + companyId: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + isActive: false, + isDefault: false, + }), + }); + + const result = await assembler.assemble({ + companyId: UniqueID.generateNewID(), + proforma: buildProforma(), + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /inactive/i); +}); + +test("assemble propaga el fallo si el payment method no existe o no pertenece a la company", async () => { + const { assembler } = buildAssembler({ + paymentMethodResult: Result.fail(new Error("Payment method not found in company scope")), + }); + + const result = await assembler.assemble({ + companyId: UniqueID.generateNewID(), + proforma: buildProforma(), + transaction: undefined, + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /not found/i); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts index 17ae5c53..c5614faf 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/assemblers/proforma-issue-read-model.assembler.ts @@ -59,6 +59,14 @@ export class ProformaIssueReadModelAssembler implements IProformaIssueReadModelA return Result.fail(paymentMethodResult.error); } + if (!paymentMethodResult.data.isActive) { + return Result.fail( + new Error( + `Payment method is inactive and cannot be used to issue proforma: ${paymentMethodId.toString()}` + ) + ); + } + const paymentTermResult = await this.resolvePaymentTerm(params); if (paymentTermResult.isFailure) { diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index da31af60..32e33895 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -8,6 +8,7 @@ export * from "./proforma-document-properties-factory"; export * from "./proforma-deleter"; export * from "./proforma-finder"; export * from "./proforma-issuer"; +export * from "./proforma-issuance-readiness-checker"; export * from "./proforma-number-generator.interface"; export * from "./proforma-public-services.interface"; export * from "./proforma-series-validator.interface"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts new file mode 100644 index 00000000..f0d71c6a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { CurrencyCode, LanguageCode, UniqueID, UtcDate } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import { + InvoiceNumber, + InvoiceStatus, + Proforma, + ProformaItems, + ProformaTaxConfig, +} from "../../../domain"; + +import { ProformaIssuanceReadinessChecker } from "./proforma-issuance-readiness-checker"; + +const buildApprovedProforma = (params?: { + operationDate?: Maybe; + description?: Maybe; + paymentMethodId?: Maybe; + taxRegimeCode?: Maybe; + itemsCount?: number; +}) => { + const proformaDateResult = UtcDate.createFromISO("2026-08-16"); + const operationDateResult = UtcDate.createFromISO("2026-08-17"); + const currencyCodeResult = CurrencyCode.create("EUR"); + const languageCodeResult = LanguageCode.create("es"); + const referenceResult = InvoiceNumber.create("PF-2026-99"); + const discountResult = DiscountPercentage.create({ value: 0 }); + const taxConfigResult = ProformaTaxConfig.create({ + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }); + + assert.equal(proformaDateResult.isSuccess, true); + assert.equal(operationDateResult.isSuccess, true); + assert.equal(currencyCodeResult.isSuccess, true); + assert.equal(languageCodeResult.isSuccess, true); + assert.equal(referenceResult.isSuccess, true); + assert.equal(discountResult.isSuccess, true); + assert.equal(taxConfigResult.isSuccess, true); + + return Proforma.rehydrate( + { + companyId: UniqueID.generateNewID(), + status: InvoiceStatus.approved(), + archivedAt: Maybe.none(), + documentSeriesId: Maybe.none(), + proformaSeriesCode: Maybe.none(), + proformaNumber: Maybe.none(), + proformaReference: referenceResult.data, + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: proformaDateResult.data, + operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data), + customerId: UniqueID.generateNewID(), + recipient: Maybe.some({ + name: "Acme", + tin: Maybe.some("ESB12345678" as never), + street: Maybe.some("Calle Mayor 1" as never), + street2: Maybe.none(), + city: Maybe.some("Madrid" as never), + postalCode: Maybe.some("28001" as never), + province: Maybe.some("Madrid" as never), + country: Maybe.some("ES" as never), + } as never), + reference: Maybe.none(), + description: params?.description ?? Maybe.some("Servicio mensual"), + notes: Maybe.none(), + languageCode: languageCodeResult.data, + currencyCode: currencyCodeResult.data, + linkedInvoiceId: Maybe.none(), + paymentMethodId: params?.paymentMethodId ?? Maybe.some(UniqueID.generateNewID()), + paymentTermId: Maybe.none(), + taxRegimeCode: params?.taxRegimeCode ?? Maybe.some("01"), + taxConfig: taxConfigResult.data, + globalDiscountPercentage: discountResult.data, + }, + ProformaItems.create({ + items: Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never), + }), + UniqueID.generateNewID() + ); +}; + +test("check devuelve canIssue true cuando la proforma y el payment method son válidos", async () => { + const checker = new ProformaIssuanceReadinessChecker({ + paymentMethodFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: UniqueID.generateNewID(), + companyId: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + isActive: true, + isDefault: false, + }) + ), + } as never, + }); + + const result = await checker.check({ + companyId: UniqueID.generateNewID(), + proforma: buildApprovedProforma(), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.canIssue, true); + assert.deepEqual(result.data.blockers, []); +}); + +test("check acumula blockers internos sin abortar en el primero", async () => { + const checker = new ProformaIssuanceReadinessChecker({ + paymentMethodFinder: { + findByIdInCompany: async () => Result.ok(Maybe.none()), + } as never, + }); + + const result = await checker.check({ + companyId: UniqueID.generateNewID(), + proforma: buildApprovedProforma({ + operationDate: Maybe.none(), + description: Maybe.none(), + paymentMethodId: Maybe.none(), + taxRegimeCode: Maybe.none(), + itemsCount: 0, + }), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.equal(result.data.canIssue, false); + assert.deepEqual( + result.data.blockers.map((blocker) => blocker.code), + [ + "missing_description", + "missing_operation_date", + "missing_items", + "missing_payment_method", + "missing_tax_regime", + ] + ); +}); + +test("check informa payment_method_unavailable cuando el finder no resuelve la referencia company-scoped", async () => { + const checker = new ProformaIssuanceReadinessChecker({ + paymentMethodFinder: { + findByIdInCompany: async () => Result.ok(Maybe.none()), + } as never, + }); + + const result = await checker.check({ + companyId: UniqueID.generateNewID(), + proforma: buildApprovedProforma(), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.deepEqual( + result.data.blockers.map((blocker) => blocker.code), + ["payment_method_unavailable"] + ); +}); + +test("check informa payment_method_inactive cuando la referencia resuelta está inactiva", async () => { + const checker = new ProformaIssuanceReadinessChecker({ + paymentMethodFinder: { + findByIdInCompany: async () => + Result.ok( + Maybe.some({ + id: UniqueID.generateNewID(), + companyId: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + isActive: false, + isDefault: false, + }) + ), + } as never, + }); + + const result = await checker.check({ + companyId: UniqueID.generateNewID(), + proforma: buildApprovedProforma(), + transaction: undefined, + }); + + assert.equal(result.isSuccess, true); + assert.deepEqual( + result.data.blockers.map((blocker) => blocker.code), + ["payment_method_inactive"] + ); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts new file mode 100644 index 00000000..f8ab718e --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-issuance-readiness-checker.ts @@ -0,0 +1,63 @@ +import type { IPaymentMethodPublicFinder } from "@erp/catalogs/api"; +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { Proforma } from "../../../domain"; +import type { ProformaIssuanceReadinessModel } from "../models"; + +export interface IProformaIssuanceReadinessChecker { + check(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise>; +} + +export class ProformaIssuanceReadinessChecker implements IProformaIssuanceReadinessChecker { + public constructor( + private readonly deps: { + paymentMethodFinder: IPaymentMethodPublicFinder; + } + ) {} + + public async check(params: { + companyId: UniqueID; + proforma: Proforma; + transaction?: unknown; + }): Promise> { + const blockers: ProformaIssuanceReadinessModel["blockers"] = params.proforma + .getIssuanceBlockers() + .map((blocker) => ({ + code: blocker.code, + })); + + if (params.proforma.paymentMethodId.isSome()) { + const paymentMethodId = params.proforma.paymentMethodId.unwrap(); + + const paymentMethodResult = await this.deps.paymentMethodFinder.findByIdInCompany({ + companyId: params.companyId, + id: paymentMethodId, + transaction: params.transaction, + }); + + if (paymentMethodResult.isFailure) { + return Result.fail(paymentMethodResult.error); + } + + if (paymentMethodResult.data.isNone()) { + blockers.push({ + code: "payment_method_unavailable", + }); + } else if (!paymentMethodResult.data.unwrap().isActive) { + blockers.push({ + code: "payment_method_inactive", + }); + } + } + + return Result.ok({ + canIssue: blockers.length === 0, + blockers, + }); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts index 5a9529ba..9b0ab512 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/index.ts @@ -1,4 +1,5 @@ export * from "./full"; +export * from "./issuance-readiness"; export * from "./issue"; export * from "./report"; export * from "./summary"; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/index.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/index.ts new file mode 100644 index 00000000..8eb1a0e2 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/index.ts @@ -0,0 +1 @@ +export * from "./proforma-issuance-readiness.snapshot-builder"; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts new file mode 100644 index 00000000..0c01752b --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/issuance-readiness/proforma-issuance-readiness.snapshot-builder.ts @@ -0,0 +1,22 @@ +import type { ISnapshotBuilder } from "@erp/core/api"; + +import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../../common"; +import type { ProformaIssuanceReadinessModel } from "../../models"; + +export interface IProformaIssuanceReadinessSnapshotBuilder + extends ISnapshotBuilder {} + +export class ProformaIssuanceReadinessSnapshotBuilder + implements IProformaIssuanceReadinessSnapshotBuilder +{ + public toOutput( + source: ProformaIssuanceReadinessModel + ): GetProformaIssuanceReadinessResponseDTO { + return { + can_issue: source.canIssue, + blockers: source.blockers.map((blocker) => ({ + code: blocker.code, + })), + }; + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.test.ts new file mode 100644 index 00000000..53641bda --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { GetProformaIssuanceReadinessUseCase } from "./get-proforma-issuance-readiness.use-case"; + +const buildTransactionManager = (): ITransactionManager => ({ + start: async () => undefined, + getTransaction: () => undefined, + complete: async (work: (transaction: unknown) => Promise) => work(undefined), + commit: async () => undefined, + rollback: async () => undefined, +}); + +test("execute devuelve el snapshot de readiness para una proforma encontrada", async () => { + const companyId = UniqueID.generateNewID(); + const proformaId = UniqueID.generateNewID(); + const readiness = { + canIssue: false, + blockers: [{ code: "missing_operation_date" as const }], + }; + + const useCase = new GetProformaIssuanceReadinessUseCase({ + finder: { + findProformaByIdIncludingArchived: async ( + receivedCompanyId: UniqueID, + receivedProformaId: UniqueID + ) => { + assert.equal(receivedCompanyId.equals(companyId), true); + assert.equal(receivedProformaId.equals(proformaId), true); + return Result.ok({ id: proformaId } as never); + }, + } as never, + readinessChecker: { + check: async ({ + companyId: receivedCompanyId, + proforma, + }: { + companyId: UniqueID; + proforma: { id: UniqueID }; + }) => { + assert.equal(receivedCompanyId.equals(companyId), true); + assert.equal((proforma as { id: UniqueID }).id.equals(proformaId), true); + return Result.ok(readiness); + }, + } as never, + snapshotBuilder: { + toOutput: (input: typeof readiness) => ({ + can_issue: input.canIssue, + blockers: input.blockers, + }), + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId, + proforma_id: proformaId.toPrimitive(), + }); + + assert.equal(result.isSuccess, true); + assert.deepEqual(result.data, { + can_issue: false, + blockers: [{ code: "missing_operation_date" }], + }); +}); + +test("execute falla si el identificador de proforma no es válido", async () => { + const useCase = new GetProformaIssuanceReadinessUseCase({ + finder: {} as never, + readinessChecker: {} as never, + snapshotBuilder: {} as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId: UniqueID.generateNewID(), + proforma_id: "invalid-id", + }); + + assert.equal(result.isFailure, true); +}); diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.ts new file mode 100644 index 00000000..07ba522a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/get-proforma-issuance-readiness.use-case.ts @@ -0,0 +1,60 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IProformaFinder, IProformaIssuanceReadinessChecker } from "../services"; +import type { IProformaIssuanceReadinessSnapshotBuilder } from "../snapshot-builders"; + +type GetProformaIssuanceReadinessUseCaseInput = { + companyId: UniqueID; + proforma_id: string; +}; + +export class GetProformaIssuanceReadinessUseCase { + public constructor( + private readonly deps: { + finder: IProformaFinder; + readinessChecker: IProformaIssuanceReadinessChecker; + snapshotBuilder: IProformaIssuanceReadinessSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: GetProformaIssuanceReadinessUseCaseInput) { + const idOrError = UniqueID.create(params.proforma_id); + + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + const proformaId = idOrError.data; + + return this.deps.transactionManager.complete(async (transaction) => { + try { + const proformaResult = await this.deps.finder.findProformaByIdIncludingArchived( + params.companyId, + proformaId, + transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const readinessResult = await this.deps.readinessChecker.check({ + companyId: params.companyId, + proforma: proformaResult.data, + transaction, + }); + + if (readinessResult.isFailure) { + return Result.fail(readinessResult.error); + } + + return Result.ok(this.deps.snapshotBuilder.toOutput(readinessResult.data)); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts index 9a86e40e..d7444e47 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts @@ -4,6 +4,7 @@ export * from "./create-proforma.use-case"; export * from "./duplicate-proforma-by-id.use-case"; export * from "./delete-proforma-by-id.use-case"; export * from "./get-proforma-by-id.use-case"; +export * from "./get-proforma-issuance-readiness.use-case"; export * from "./issue-proforma.use-case"; export * from "./list-proformas.use-case"; export * from "./preview-proforma-report.use-case"; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts index 23c7fe6c..8cb0f0ac 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/issue-proforma.use-case.test.ts @@ -1,10 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DiscountPercentage } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api"; -import { UniqueID } from "@repo/rdx-ddd"; -import { Result } from "@repo/rdx-utils"; +import { CurrencyCode, LanguageCode, UtcDate, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; +import { + CompanyVerifactuProfileStatus, + InvoiceNumber, + InvoiceStatus, + Proforma, + ProformaItems, + ProformaTaxConfig, +} from "../../../domain"; +import { ProformaIssuer } from "../services/proforma-issuer"; import { IssueProformaUseCase } from "./issue-proforma.use-case"; const buildTransactionManager = (): ITransactionManager => ({ @@ -15,6 +25,72 @@ const buildTransactionManager = (): ITransactionManager => ({ rollback: async () => undefined, }); +const buildIssueableProforma = (params?: { + operationDate?: Maybe; +}) => { + const proformaDateResult = UtcDate.createFromISO("2026-08-16"); + const operationDateResult = UtcDate.createFromISO("2026-08-17"); + const languageCodeResult = LanguageCode.create("es"); + const currencyCodeResult = CurrencyCode.create("EUR"); + const discountResult = DiscountPercentage.create({ value: 0 }); + const proformaReferenceResult = InvoiceNumber.create("PF-2026-1"); + const taxConfigResult = ProformaTaxConfig.create({ + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }); + + assert.equal(proformaDateResult.isSuccess, true); + assert.equal(operationDateResult.isSuccess, true); + assert.equal(languageCodeResult.isSuccess, true); + assert.equal(currencyCodeResult.isSuccess, true); + assert.equal(discountResult.isSuccess, true); + assert.equal(proformaReferenceResult.isSuccess, true); + assert.equal(taxConfigResult.isSuccess, true); + + return Proforma.rehydrate( + { + companyId: UniqueID.generateNewID(), + status: InvoiceStatus.approved(), + archivedAt: Maybe.none(), + documentSeriesId: Maybe.none(), + proformaSeriesCode: Maybe.none(), + proformaNumber: Maybe.none(), + proformaReference: proformaReferenceResult.data, + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: proformaDateResult.data, + operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data), + customerId: UniqueID.generateNewID(), + recipient: Maybe.some({ + name: "Acme", + tin: Maybe.some("ESB12345678" as never), + street: Maybe.some("Calle Mayor 1" as never), + street2: Maybe.none(), + city: Maybe.some("Madrid" as never), + postalCode: Maybe.some("28001" as never), + province: Maybe.some("Madrid" as never), + country: Maybe.some("ES" as never), + } as never), + reference: Maybe.none(), + description: Maybe.some("Servicio mensual"), + notes: Maybe.none(), + languageCode: languageCodeResult.data, + currencyCode: currencyCodeResult.data, + linkedInvoiceId: Maybe.none(), + paymentMethodId: Maybe.some(UniqueID.generateNewID()), + paymentTermId: Maybe.none(), + taxRegimeCode: Maybe.some("01"), + taxConfig: taxConfigResult.data, + globalDiscountPercentage: discountResult.data, + }, + ProformaItems.create({ items: [{} as never] }), + UniqueID.generateNewID() + ); +}; + test("issue rechaza un payment term cross-tenant y no crea factura parcial", async () => { const companyId = UniqueID.generateNewID(); const proformaId = UniqueID.generateNewID(); @@ -72,3 +148,74 @@ test("issue rechaza un payment term cross-tenant y no crea factura parcial", asy assert.equal(createIssuedInvoiceCalls, 0); assert.equal(markAsIssuedCalls, 0); }); + +test("issue falla antes de persistir si la proforma no tiene operation date", async () => { + const proforma = buildIssueableProforma({ + operationDate: Maybe.none(), + }); + + let createIssuedInvoiceCalls = 0; + let markAsIssuedCalls = 0; + + const useCase = new IssueProformaUseCase({ + issuedInvoiceServices: { + createIssuedInvoice: async () => { + createIssuedInvoiceCalls += 1; + return Result.ok({ id: UniqueID.generateNewID() } as never); + }, + } as never, + finder: { + findProformaById: async () => Result.ok(proforma), + } as never, + issuer: new ProformaIssuer({ + proformaConverter: { + toCreateProps: () => Result.fail(new Error("converter should not be reached")), + }, + }), + issueReadModelAssembler: { + assemble: async () => + Result.ok({ + proforma, + paymentMethod: { + id: UniqueID.generateNewID(), + name: "Transferencia", + description: Maybe.none(), + }, + paymentTerm: Maybe.none(), + taxRegime: { + code: "01", + description: "Régimen general", + }, + fiscalClassification: { + operationQualificationCode: null, + exemptionReasonCode: null, + }, + verifactuPolicy: { + profileStatus: CompanyVerifactuProfileStatus.NotConfigured, + usesVerifactu: false, + effectiveAt: null, + }, + }), + } as never, + issueResponseSnapshotBuilder: { + toOutput: () => ({ id: "unused" }), + } as never, + repository: { + markAsIssuedByIdInCompany: async () => { + markAsIssuedCalls += 1; + return Result.ok(undefined); + }, + } as never, + transactionManager: buildTransactionManager(), + }); + + const result = await useCase.execute({ + companyId: proforma.companyId.toPrimitive(), + proformaId: proforma.id.toPrimitive(), + }); + + assert.equal(result.isFailure, true); + assert.match(result.error.message, /operation date is required/i); + assert.equal(createIssuedInvoiceCalls, 0); + assert.equal(markAsIssuedCalls, 0); +}); diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts new file mode 100644 index 00000000..97ed4c05 --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma-issue-guards.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DiscountPercentage } from "@erp/core/api"; +import { + CurrencyCode, + DomainValidationError, + LanguageCode, + UniqueID, + UtcDate, +} from "@repo/rdx-ddd"; +import { Maybe } from "@repo/rdx-utils"; + +import { InvoiceNumber, InvoiceStatus } from "../../common"; +import { ProformaItems } from "../entities"; +import { ProformaTaxConfig } from "../value-objects"; + +import { Proforma } from "./proforma.aggregate"; + +const buildApprovedProforma = (params?: { + description?: Maybe; + operationDate?: Maybe; + paymentMethodId?: Maybe; + itemsCount?: number; + status?: InvoiceStatus; + linkedInvoiceId?: Maybe; +}) => { + const proformaDateResult = UtcDate.createFromISO("2026-08-16"); + const operationDateResult = UtcDate.createFromISO("2026-08-17"); + const currencyCodeResult = CurrencyCode.create("EUR"); + const languageCodeResult = LanguageCode.create("es"); + const proformaReferenceResult = InvoiceNumber.create("PF-2026-1"); + const discountResult = DiscountPercentage.create({ value: 0 }); + const taxConfigResult = ProformaTaxConfig.create({ + taxMode: "single", + defaultIvaCode: Maybe.none(), + usesEquivalenceSurcharge: false, + defaultRecCode: Maybe.none(), + usesRetention: false, + defaultRetentionCode: Maybe.none(), + }); + + assert.equal(proformaDateResult.isSuccess, true); + assert.equal(operationDateResult.isSuccess, true); + assert.equal(currencyCodeResult.isSuccess, true); + assert.equal(languageCodeResult.isSuccess, true); + assert.equal(proformaReferenceResult.isSuccess, true); + assert.equal(discountResult.isSuccess, true); + assert.equal(taxConfigResult.isSuccess, true); + + const items = Array.from({ length: params?.itemsCount ?? 1 }, () => ({}) as never); + + return Proforma.rehydrate( + { + companyId: UniqueID.generateNewID(), + status: params?.status ?? InvoiceStatus.approved(), + archivedAt: Maybe.none(), + documentSeriesId: Maybe.none(), + proformaSeriesCode: Maybe.none(), + proformaNumber: Maybe.none(), + proformaReference: proformaReferenceResult.data, + targetInvoiceSeriesCode: Maybe.none(), + proformaDate: proformaDateResult.data, + operationDate: params?.operationDate ?? Maybe.some(operationDateResult.data), + customerId: UniqueID.generateNewID(), + recipient: Maybe.some({ + name: "Acme", + tin: Maybe.some("ESB12345678" as never), + street: Maybe.some("Calle Mayor 1" as never), + street2: Maybe.none(), + city: Maybe.some("Madrid" as never), + postalCode: Maybe.some("28001" as never), + province: Maybe.some("Madrid" as never), + country: Maybe.some("ES" as never), + } as never), + reference: Maybe.none(), + description: params?.description ?? Maybe.some("Servicio mensual"), + notes: Maybe.none(), + languageCode: languageCodeResult.data, + currencyCode: currencyCodeResult.data, + linkedInvoiceId: params?.linkedInvoiceId ?? Maybe.none(), + paymentMethodId: params?.paymentMethodId ?? Maybe.some(UniqueID.generateNewID()), + paymentTermId: Maybe.none(), + taxRegimeCode: Maybe.some("01"), + taxConfig: taxConfigResult.data, + globalDiscountPercentage: discountResult.data, + }, + ProformaItems.create({ items }), + UniqueID.generateNewID() + ); +}; + +test("markAsIssued rechaza proformas sin operationDate", () => { + const proforma = buildApprovedProforma({ + operationDate: Maybe.none(), + }); + + const result = proforma.markAsIssued(); + + assert.equal(result.isFailure, true); + assert.equal(result.error instanceof DomainValidationError, true); + assert.equal((result.error as DomainValidationError).field, "operationDate"); + assert.equal( + (result.error as DomainValidationError).detail, + "Operation date is required to issue the proforma" + ); +}); + +test("markAsIssued mantiene el rechazo cuando falta description", () => { + const proforma = buildApprovedProforma({ + description: Maybe.none(), + }); + + const result = proforma.markAsIssued(); + + assert.equal(result.isFailure, true); + assert.equal(result.error instanceof DomainValidationError, true); + assert.equal((result.error as DomainValidationError).field, "description"); + assert.equal( + (result.error as DomainValidationError).detail, + "Description is required to issue the proforma" + ); +}); + +test("markAsIssued acepta la proforma cuando cumple todas las precondiciones", () => { + const proforma = buildApprovedProforma(); + + const result = proforma.markAsIssued(); + + assert.equal(result.isSuccess, true); + assert.equal(proforma.status.isIssued(), true); +}); + +test("getIssuanceBlockers enumera varios blockers simultáneamente", () => { + const proforma = buildApprovedProforma({ + description: Maybe.none(), + operationDate: Maybe.none(), + paymentMethodId: Maybe.none(), + itemsCount: 0, + }); + + const blockers = proforma.getIssuanceBlockers(); + + assert.deepEqual( + blockers.map((blocker) => blocker.code), + ["missing_description", "missing_operation_date", "missing_items", "missing_payment_method"] + ); +}); + +test("getIssuanceBlockers informa already_issued cuando existe linked invoice", () => { + const proforma = buildApprovedProforma({ + linkedInvoiceId: Maybe.some(UniqueID.generateNewID()), + }); + + const blockers = proforma.getIssuanceBlockers(); + + assert.deepEqual( + blockers.map((blocker) => blocker.code), + ["already_issued"] + ); +}); diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 897e2e87..530cf27c 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -101,6 +101,31 @@ export interface IProformaTotals { totalAmount: InvoiceAmount; } +export type ProformaIssuanceBlockerCode = + | "already_issued" + | "invalid_status" + | "missing_description" + | "missing_operation_date" + | "incomplete_recipient" + | "missing_items" + | "missing_payment_method" + | "missing_tax_regime"; + +export interface ProformaIssuanceBlocker { + code: ProformaIssuanceBlockerCode; + errorCode: + | "ALREADY_ISSUED" + | "INVALID_STATUS" + | "MISSING_DESCRIPTION" + | "MISSING_OPERATION_DATE" + | "MISSING_RECIPIENT" + | "NO_ITEMS" + | "MISSING_PAYMENT_METHOD" + | "MISSING_TAX_REGIME"; + field: string; + detail: string; +} + export interface IProforma { companyId: UniqueID; status: InvoiceStatus; @@ -387,26 +412,6 @@ export class Proforma extends AggregateRoot implements IP } public markAsIssued(): Result { - const currentStatus = this.status; - - if (currentStatus.isIssued()) { - return Result.ok(); - } - - // Antes de cambiar el estado de la proforma, - // comprobamos que se cumplen las condiciones - // necesarias. - - if (!currentStatus.isApproved()) { - return Result.fail( - new InvalidProformaTransitionError( - currentStatus.toPrimitive(), - INVOICE_STATUS.ISSUED, - this.id.toString() - ) - ); - } - const validationResult = this.validateCanBeIssued(); if (validationResult.isFailure) { @@ -418,6 +423,102 @@ export class Proforma extends AggregateRoot implements IP return Result.ok(); } + public getIssuanceBlockers(): ProformaIssuanceBlocker[] { + const blockers: ProformaIssuanceBlocker[] = []; + const currentStatus = this.status; + + if (currentStatus.isIssued() || this.linkedInvoiceId.isSome()) { + blockers.push({ + code: "already_issued", + errorCode: "ALREADY_ISSUED", + field: "linkedInvoiceId", + detail: "Proforma is already linked to an invoice", + }); + } else if (!currentStatus.isApproved()) { + blockers.push({ + code: "invalid_status", + errorCode: "INVALID_STATUS", + field: "status", + detail: `Invalid transition for proforma ${this.id.toString()}: ${currentStatus.toPrimitive()} → ${INVOICE_STATUS.ISSUED}`, + }); + } + + if (this.description.isNone()) { + blockers.push({ + code: "missing_description", + errorCode: "MISSING_DESCRIPTION", + field: "description", + detail: "Description is required to issue the proforma", + }); + } + + if (this.operationDate.isNone()) { + blockers.push({ + code: "missing_operation_date", + errorCode: "MISSING_OPERATION_DATE", + field: "operationDate", + detail: "Operation date is required to issue the proforma", + }); + } + + if (this.recipient.isNone()) { + blockers.push({ + code: "incomplete_recipient", + errorCode: "MISSING_RECIPIENT", + field: "recipient", + detail: "Recipient is required to issue the proforma", + }); + } else { + const recipientValue = this.recipient.unwrap(); + + if ( + !recipientValue.name || + recipientValue.tin.isNone() || + recipientValue.street.isNone() || + recipientValue.city.isNone() || + recipientValue.postalCode.isNone() || + recipientValue.province.isNone() || + recipientValue.country.isNone() + ) { + blockers.push({ + code: "incomplete_recipient", + errorCode: "MISSING_RECIPIENT", + field: "recipient", + detail: "Recipient is required to issue the proforma", + }); + } + } + + if (this.items.size() === 0) { + blockers.push({ + code: "missing_items", + errorCode: "NO_ITEMS", + field: "items", + detail: "At least one item is required to issue the proforma", + }); + } + + if (this.paymentMethodId.isNone()) { + blockers.push({ + code: "missing_payment_method", + errorCode: "MISSING_PAYMENT_METHOD", + field: "paymentMethod", + detail: "Payment method is required to issue the proforma", + }); + } + + if (this.taxRegimeCode.isNone()) { + blockers.push({ + code: "missing_tax_regime", + errorCode: "MISSING_TAX_REGIME", + field: "taxRegime", + detail: "Tax regime is required to issue the proforma", + }); + } + + return blockers; + } + public ensureCanBeDeleted(): Result { if (!this.status.isDraft()) { return Result.fail( @@ -486,114 +587,21 @@ export class Proforma extends AggregateRoot implements IP } private validateCanBeIssued(): Result { - if (this.description.isNone()) { - return Result.fail( - new DomainValidationError( - "MISSING_DESCRIPTION", - "description", - "Description is required to issue the proforma" - ) - ); - } + const blocker = this.getIssuanceBlockers()[0]; - if (this.recipient.isNone()) { - return Result.fail( - new DomainValidationError( - "MISSING_RECIPIENT", - "recipient", - "Recipient is required to issue the proforma" - ) - ); - } + if (blocker) { + if (blocker.code === "invalid_status") { + return Result.fail( + new InvalidProformaTransitionError( + this.status.toPrimitive(), + INVOICE_STATUS.ISSUED, + this.id.toString() + ) + ); + } - const recipientValue = this.recipient.unwrap(); - - if ( - !recipientValue.name || - recipientValue.tin.isNone() || - recipientValue.street.isNone() || - recipientValue.city.isNone() || - recipientValue.postalCode.isNone() || - recipientValue.province.isNone() || - recipientValue.country.isNone() - ) { return Result.fail( - new DomainValidationError( - "MISSING_RECIPIENT", - "recipient", - "Recipient is required to issue the proforma" - ) - ); - } - - if (this.items.size() === 0) { - return Result.fail( - new DomainValidationError( - "NO_ITEMS", - "items", - "At least one item is required to issue the proforma" - ) - ); - } - - /*const invalidItem = this.items.find((item) => !item.isValued()); - if (invalidItem) { - return Result.fail( - new DomainValidationError( - "INVALID_ITEM", - "items", - `Item with id ${invalidItem.id} is not valid for invoicing` - ) - ); - }*/ - - if (this.paymentMethodId.isNone()) { - return Result.fail( - new DomainValidationError( - "MISSING_PAYMENT_METHOD", - "paymentMethod", - "Payment method is required to issue the proforma" - ) - ); - } - - if (this.taxRegimeCode.isNone()) { - return Result.fail( - new DomainValidationError( - "MISSING_TAX_REGIME", - "taxRegime", - "Tax regime is required to issue the proforma" - ) - ); - } - - /*if (this.operationDate.isSome() && this.operationDate.unwrap() > new Date()) { - return Result.fail( - new DomainValidationError( - "INVALID_OPERATION_DATE", - "operationDate", - "Operation date cannot be in the future" - ) - ); - } - - if (this.operationDate.isSome() && this.operationDate.unwrap() < this.invoiceDate) { - return Result.fail( - new DomainValidationError( - "INVALID_OPERATION_DATE", - "operationDate", - "Operation date cannot be before invoice date" - ) - ); - }*/ - - if (this.linkedInvoiceId.isSome()) { - return Result.fail( - new DomainValidationError( - "LINKED_INVOICE_NOT_ALLOWED", - "linkedInvoiceId", - "Proforma is already linked to an invoice" - ) + new DomainValidationError(blocker.errorCode, blocker.field, blocker.detail) ); } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index 0998cc59..dcdcded8 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -10,6 +10,7 @@ import { type DeleteProformaByIdUseCase, type DuplicateProformaByIdUseCase, type GetProformaByIdUseCase, + type GetProformaIssuanceReadinessUseCase, type IIssuedInvoicePublicServices, type IssueProformaUseCase, type ListProformasUseCase, @@ -23,6 +24,7 @@ import { buildDeleteProformaByIdUseCase, buildDuplicateProformaByIdUseCase, buildGetProformaByIdUseCase, + buildGetProformaIssuanceReadinessUseCase, buildIssueProformaUseCase, buildListProformasUseCase, buildPreviewProformaReportUseCase, @@ -33,6 +35,7 @@ import { buildProformaFinder, buildProformaInputMappers, buildProformaIssueReadModelAssembler, + ProformaIssuanceReadinessChecker, buildProformaIssuer, buildProformaReadModelAssemblers, buildProformaSnapshotBuilders, @@ -59,6 +62,7 @@ export type ProformasInternalDeps = { useCases: { listProformas: () => ListProformasUseCase; getProformaById: () => GetProformaByIdUseCase; + getProformaIssuanceReadiness: () => GetProformaIssuanceReadinessUseCase; reportProformaPdf: () => ReportProformaPdfUseCase; previewProformaReport: () => PreviewProformaReportUseCase; createProforma: () => CreateProformaUseCase; @@ -162,6 +166,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter customerServices, companyVerifactuProfileRepository, }); + const issuanceReadinessChecker = new ProformaIssuanceReadinessChecker({ + paymentMethodFinder: catalogs.paymentMethod.finder, + }); const companyReportProfileFinder = new CompanyReportProfileFinder({ companiesServices, @@ -185,6 +192,14 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter transactionManager, }), + getProformaIssuanceReadiness: () => + buildGetProformaIssuanceReadinessUseCase({ + finder, + readinessChecker: issuanceReadinessChecker, + snapshotBuilder: snapshotBuilders.issuanceReadiness, + transactionManager, + }), + createProforma: () => buildCreateProformaUseCase({ creator, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.test.ts new file mode 100644 index 00000000..c223189f --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import type { NextFunction, Request, Response } from "express"; + +import { GetProformaIssuanceReadinessController } from "./get-proforma-issuance-readiness.controller"; + +function createResponseDouble() { + return { + statusCode: 200, + headers: {} as Record, + body: undefined as unknown, + type() { + return this; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + sendStatus(code: number) { + this.statusCode = code; + return this; + }, + setHeader(key: string, value: string) { + this.headers[key] = value; + return this; + }, + set(values: Record) { + Object.assign(this.headers, values); + return this; + }, + send(payload?: unknown) { + this.body = payload; + return this; + }, + }; +} + +function createRequestDouble(overrides: Partial = {}): Request { + return { + method: "GET", + protocol: "http", + originalUrl: "/api/proformas/11111111-1111-4111-8111-111111111111/issuance-readiness", + query: {}, + params: { + proforma_id: "11111111-1111-4111-8111-111111111111", + }, + body: {}, + get: () => undefined, + ...overrides, + } as Request; +} + +test("controller devuelve 401 sin autenticación", async () => { + const controller = new GetProformaIssuanceReadinessController({ + execute: async () => { + throw new Error("should not run"); + }, + } as never); + const res = createResponseDouble(); + + await controller.execute( + createRequestDouble(), + res as never as Response, + (() => undefined) as NextFunction + ); + + assert.equal(res.statusCode, 401); +}); + +test("controller devuelve 403 si falta company context", async () => { + const controller = new GetProformaIssuanceReadinessController({ + execute: async () => { + throw new Error("should not run"); + }, + } as never); + const res = createResponseDouble(); + const req = createRequestDouble({ + user: { + userId: UniqueID.generateNewID(), + }, + } as Partial); + + await controller.execute(req, res as never as Response, (() => undefined) as NextFunction); + + assert.equal(res.statusCode, 403); +}); + +test("controller devuelve 200 con can_issue false y blockers tipados", async () => { + const companyId = UniqueID.generateNewID(); + const controller = new GetProformaIssuanceReadinessController({ + execute: async ({ + companyId: receivedCompanyId, + proforma_id, + }: { + companyId: UniqueID; + proforma_id: string; + }) => { + assert.equal(receivedCompanyId.equals(companyId), true); + assert.equal(proforma_id, "11111111-1111-4111-8111-111111111111"); + + return Result.ok({ + can_issue: false, + blockers: [{ code: "missing_operation_date" as const }], + }); + }, + } as never); + const res = createResponseDouble(); + const req = createRequestDouble({ + user: { + userId: UniqueID.generateNewID(), + companyId, + }, + } as Partial); + + await controller.execute(req, res as never as Response, (() => undefined) as NextFunction); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { + can_issue: false, + blockers: [{ code: "missing_operation_date" }], + }); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.ts new file mode 100644 index 00000000..c0d2626e --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/get-proforma-issuance-readiness.controller.ts @@ -0,0 +1,43 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import { GetProformaIssuanceReadinessResponseSchema } from "../../../../../common"; +import type { GetProformaIssuanceReadinessUseCase } from "../../../../application"; +import { proformasApiErrorMapper } from "../proformas-api-error-mapper"; + +export class GetProformaIssuanceReadinessController extends ExpressController { + public constructor(private readonly useCase: GetProformaIssuanceReadinessUseCase) { + super(); + this.errorMapper = proformasApiErrorMapper; + + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { proforma_id } = this.req.params; + + const result = await this.useCase.execute({ + proforma_id, + companyId, + }); + + return result.match( + (data) => this.ok(GetProformaIssuanceReadinessResponseSchema.parse(data)), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts index 172c4235..5ada4bbf 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts @@ -3,6 +3,7 @@ export * from "./archive-proforma-by-id.controller"; //export * from "./create-proforma.controller"; export * from "./delete-proforma.controller"; export * from "./get-proforma-by-id.controller"; +export * from "./get-proforma-issuance-readiness.controller"; export * from "./issue-proforma.controller"; export * from "./list-proformas.controller"; export * from "./preview-proforma-report.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index 7cdc1783..4637a770 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -6,6 +6,7 @@ import { ArchiveProformaByIdController, DeleteProformaController, GetProformaByIdController, + GetProformaIssuanceReadinessController, IssueProformaController, ListProformasController, PreviewProformaReportController, @@ -22,6 +23,7 @@ import { DeleteProformaByIdParamsRequestSchema, DuplicateProformaByIdParamsRequestSchema, GetProformaByIdRequestSchema, + GetProformaIssuanceReadinessRequestSchema, IssueProformaByIdParamsRequestSchema, LegacyIssueProformaByIdParamsRequestSchema, ListProformasRequestSchema, @@ -64,7 +66,10 @@ export const proformasRouter = (params: StartParams) => { validateRequest(ListProformasRequestSchema, "query"), async (req: Request, res: Response, next: NextFunction) => { const useCase = deps.useCases.listProformas(); - const controller = new ListProformasController(useCase, "normal" /*, deps.presenters.list */); + const controller = new ListProformasController( + useCase, + "normal" /*, deps.presenters.list */ + ); return controller.execute(req, res, next); } ); @@ -87,7 +92,10 @@ export const proformasRouter = (params: StartParams) => { validateRequest(ListProformasRequestSchema, "query"), async (req: Request, res: Response, next: NextFunction) => { const useCase = deps.useCases.listProformas(); - const controller = new ListProformasController(useCase, "deleted" /*, deps.presenters.list */); + const controller = new ListProformasController( + useCase, + "deleted" /*, deps.presenters.list */ + ); return controller.execute(req, res, next); } ); @@ -103,6 +111,16 @@ export const proformasRouter = (params: StartParams) => { } ); + router.get( + "/:proforma_id/issuance-readiness", + validateRequest(GetProformaIssuanceReadinessRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.getProformaIssuanceReadiness(); + const controller = new GetProformaIssuanceReadinessController(useCase); + return controller.execute(req, res, next); + } + ); + router.get( "/:proforma_id/report/pdf", validateRequest(ReportProformaPdfByIdParamsRequestSchema, "params"), diff --git a/modules/customer-invoices/src/common/dto/request/proformas/get-proforma-issuance-readiness.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/get-proforma-issuance-readiness.request.dto.ts new file mode 100644 index 00000000..74e12f60 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/proformas/get-proforma-issuance-readiness.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const GetProformaIssuanceReadinessRequestSchema = z.object({ + proforma_id: z.uuid(), +}); + +export type GetProformaIssuanceReadinessRequestDTO = z.infer< + typeof GetProformaIssuanceReadinessRequestSchema +>; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/index.ts b/modules/customer-invoices/src/common/dto/request/proformas/index.ts index a5bd1098..cd6fa7db 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/index.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/index.ts @@ -4,6 +4,7 @@ export * from "./create-proforma.request.dto"; export * from "./delete-proforma-by-id.request.dto"; export * from "./duplicate-proforma-by-id.request.dto"; export * from "./get-proforma-by-id.request.dto"; +export * from "./get-proforma-issuance-readiness.request.dto"; export * from "./issue-proforma-by-id.request.dto"; export * from "./list-proformas.request.dto"; export * from "./preview-proforma-report-by-id.request.dto"; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts new file mode 100644 index 00000000..66be42e3 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-issuance-readiness.response.dto.ts @@ -0,0 +1,27 @@ +import { z } from "zod/v4"; + +export const ProformaIssuanceBlockerCodeSchema = z.enum([ + "already_issued", + "invalid_status", + "missing_description", + "missing_operation_date", + "incomplete_recipient", + "missing_items", + "missing_payment_method", + "missing_tax_regime", + "payment_method_unavailable", + "payment_method_inactive", +]); + +export const ProformaIssuanceBlockerSchema = z.object({ + code: ProformaIssuanceBlockerCodeSchema, +}); + +export const GetProformaIssuanceReadinessResponseSchema = z.object({ + can_issue: z.boolean(), + blockers: z.array(ProformaIssuanceBlockerSchema), +}); + +export type GetProformaIssuanceReadinessResponseDTO = z.infer< + typeof GetProformaIssuanceReadinessResponseSchema +>; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/index.ts b/modules/customer-invoices/src/common/dto/response/proformas/index.ts index 730c68de..d5e28228 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/index.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/index.ts @@ -1,6 +1,7 @@ export * from "./create-proforma.response.dto"; export * from "./duplicate-proforma-by-id.response.dto"; export * from "./get-proforma-by-id.response.dto"; +export * from "./get-proforma-issuance-readiness.response.dto"; export * from "./issue-proforma-by-id.response.dto"; export * from "./list-proformas.response.dto"; export * from "./update-proformas-by-id.response.dto"; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index a54f52b0..5581e095 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -141,7 +141,88 @@ "error_title": "Error issuing proforma", "not_allowed_title": "Action not allowed", "not_allowed_description": "Only proformas in \"Approved\" status can be issued.", - "unknown_error": "An unexpected error occurred" + "unknown_error": "An unexpected error occurred", + "cancel": "Cancel" + }, + "issuance_readiness": { + "blocked_title": "Cannot be issued yet", + "detail_description": "Complete the pending data before issuing this proforma.", + "update_description": "This check reflects the currently saved state. Save your changes to refresh it.", + "load_error_title": "Could not verify issuance readiness", + "load_error_description": "Please try again in a moment.", + "retry": "Retry", + "edit_action": "Edit proforma", + "dialog": { + "loading": "Checking whether the proforma is ready to be issued...", + "blocked_title": "Cannot be issued yet", + "blocked_alert_title": "Complete the following before issuing", + "blocked_description": "Proforma {{reference}} does not yet meet the requirements to issue the invoice." + }, + "update": { + "summary_one": "{{count}} requirement is still missing before this proforma can be issued", + "summary_other": "{{count}} requirements are still missing before this proforma can be issued", + "saved_state_hint": "Based on the latest saved changes.", + "dirty_state_hint": "This check reflects the latest saved state. Save your changes to refresh it.", + "ready_title": "Ready to issue", + "ready_description": "The proforma currently meets the requirements needed to be turned into an invoice.", + "loading": "Checking issuance requirements…", + "unavailable_title": "The issuance status could not be checked.", + "unavailable_description": "Try again to review the pending requirements." + }, + "blockers": { + "already_issued": "This proforma has already been issued and cannot be issued again.", + "invalid_status": "The proforma must be approved before it can be issued.", + "missing_description": "Add a description to the proforma.", + "missing_operation_date": "Fill in the operation date.", + "incomplete_recipient": "Complete the customer's fiscal details.", + "missing_items": "Add at least one line to the proforma.", + "missing_payment_method": "Select a payment method.", + "missing_tax_regime": "Select a tax regime.", + "payment_method_unavailable": "The selected payment method is no longer available.", + "payment_method_inactive": "The selected payment method is inactive. Choose an active payment method." + }, + "blocker_details": { + "already_issued": { + "title": "Already issued", + "description": "This proforma has already been turned into an invoice." + }, + "invalid_status": { + "title": "Document status", + "description": "It must be approved before it can be issued." + }, + "missing_description": { + "title": "Description", + "description": "Add a description to the proforma." + }, + "missing_operation_date": { + "title": "Operation date", + "description": "The operation date must be provided." + }, + "incomplete_recipient": { + "title": "Customer fiscal details", + "description": "Complete the customer's fiscal details." + }, + "missing_items": { + "title": "Line items", + "description": "Add at least one line to the proforma." + }, + "missing_payment_method": { + "title": "Payment method", + "description": "Select a payment method." + }, + "missing_tax_regime": { + "title": "Tax regime", + "description": "Select a tax regime." + }, + "payment_method_unavailable": { + "title": "Payment method unavailable", + "description": "The selected payment method is no longer available." + }, + "payment_method_inactive": { + "title": "Inactive payment method", + "description": "Select an active payment method." + } + } }, "delete_proforma_dialog": { "single_title": "Delete proforma", @@ -551,6 +632,21 @@ "error_title": "Customer could not be changed", "apply_error": "The proforma could not be adapted to the new customer." } + }, + "header": { + "help": { + "aria_label": "Information and issuance status of the proforma", + "aria_label_with_blockers_one": "Proforma information. {{count}} issuance requirement pending.", + "aria_label_with_blockers_other": "Proforma information. {{count}} issuance requirements pending.", + "title": "Proforma information", + "issuance_section_title": "Issuance status", + "fiscal_section_title": "About proformas", + "description": "This proforma has no fiscal validity. You can turn it into an invoice once it has been accepted by the customer." + } + }, + "items": { + "title": "Proforma details", + "description": "Manage the product or service lines included in this proforma." } }, "delete": { diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index d978aeee..86fe8a95 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -144,6 +144,86 @@ "unknown_error": "Ha ocurrido un error inesperado", "cancel": "Cancelar" }, + "issuance_readiness": { + "blocked_title": "No se puede emitir todavía", + "detail_description": "Completa los datos pendientes antes de emitir esta proforma.", + "update_description": "Esta comprobación refleja el estado guardado actualmente. Guarda los cambios para actualizarla.", + "load_error_title": "No se pudo comprobar la preparación para emitir", + "load_error_description": "Inténtalo de nuevo en unos instantes.", + "retry": "Reintentar", + "edit_action": "Editar proforma", + "dialog": { + "loading": "Comprobando si la proforma está lista para emitir...", + "blocked_title": "No se puede emitir todavía", + "blocked_alert_title": "Completa los siguientes datos antes de emitir", + "blocked_description": "La proforma {{reference}} todavía no cumple los requisitos para emitir la factura." + }, + "update": { + "summary_one": "Falta {{count}} requisito para poder emitir esta proforma", + "summary_other": "Faltan {{count}} requisitos para poder emitir esta proforma", + "saved_state_hint": "Basado en los últimos cambios guardados.", + "dirty_state_hint": "Esta comprobación refleja el último estado guardado. Guarda los cambios para actualizarla.", + "ready_title": "Lista para emitir", + "ready_description": "La proforma cumple actualmente los requisitos necesarios para convertirse en factura.", + "loading": "Comprobando requisitos de emisión…", + "unavailable_title": "No se ha podido comprobar el estado de emisión.", + "unavailable_description": "Inténtalo de nuevo para revisar los requisitos pendientes." + }, + "blockers": { + "already_issued": "Esta proforma ya fue emitida y no puede volver a emitirse.", + "invalid_status": "La proforma debe estar aprobada antes de emitirla.", + "missing_description": "Añade una descripción a la proforma.", + "missing_operation_date": "Completa la fecha de operación.", + "incomplete_recipient": "Completa los datos fiscales del cliente.", + "missing_items": "Añade al menos una línea a la proforma.", + "missing_payment_method": "Selecciona una forma de pago.", + "missing_tax_regime": "Selecciona un régimen fiscal.", + "payment_method_unavailable": "La forma de pago seleccionada ya no está disponible.", + "payment_method_inactive": "La forma de pago seleccionada está inactiva. Elige una forma de pago activa." + }, + "blocker_details": { + "already_issued": { + "title": "Ya emitida", + "description": "Esta proforma ya se convirtió en factura." + }, + "invalid_status": { + "title": "Estado documental", + "description": "Debe estar aprobada antes de emitirla." + }, + "missing_description": { + "title": "Descripción", + "description": "Añade una descripción a la proforma." + }, + "missing_operation_date": { + "title": "Fecha de operación", + "description": "Debe indicar la fecha de operación." + }, + "incomplete_recipient": { + "title": "Datos fiscales del cliente", + "description": "Completa los datos fiscales del cliente." + }, + "missing_items": { + "title": "Líneas de detalle", + "description": "Añade al menos una línea a la proforma." + }, + "missing_payment_method": { + "title": "Forma de pago", + "description": "Selecciona una forma de pago." + }, + "missing_tax_regime": { + "title": "Régimen fiscal", + "description": "Selecciona un régimen fiscal." + }, + "payment_method_unavailable": { + "title": "Forma de pago no disponible", + "description": "La forma de pago seleccionada ya no está disponible." + }, + "payment_method_inactive": { + "title": "Forma de pago inactiva", + "description": "Selecciona una forma de pago activa." + } + } + }, "delete_proforma_dialog": { "single_title": "Eliminar proforma", "single_description": "Se eliminará la proforma. Su referencia no se reutilizará.", @@ -553,6 +633,21 @@ "error_title": "No se pudo cambiar el cliente", "apply_error": "No se pudo adaptar la proforma al nuevo cliente." } + }, + "header": { + "help": { + "aria_label": "Información y estado de emisión de la proforma", + "aria_label_with_blockers_one": "Información de la proforma. {{count}} requisito pendiente de emisión.", + "aria_label_with_blockers_other": "Información de la proforma. {{count}} requisitos pendientes de emisión.", + "title": "Información de la proforma", + "issuance_section_title": "Estado para emisión", + "fiscal_section_title": "Sobre las proformas", + "description": "Esta proforma no tiene validez fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente." + } + }, + "items": { + "title": "Detalle de la proforma", + "description": "Gestiona las líneas de productos o servicios de esta proforma." } }, "delete": { diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index 626191a5..f76ef80b 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -13,9 +13,11 @@ import { useDownloadProformaPDFQuery } from "../../download-pdf"; import { useIssueProformaDialogController } from "../../issue-proforma"; import { prepareIssueProformaTarget } from "../../issue-proforma/utils"; import { + getProformaIssuanceBlockerMessages, useDuplicateProformaByIdMutation, useProformaArchiveMutation, useProformaGetQuery, + useProformaIssuanceReadinessQuery, useProformaUnarchiveMutation, } from "../../shared"; import type { @@ -25,6 +27,7 @@ import type { ProformaDetailDocumentViewModel, ProformaDetailGeneralViewModel, ProformaDetailHeaderViewModel, + ProformaDetailIssuanceReadinessViewModel, ProformaDetailPageState, } from "../entities"; import { @@ -87,6 +90,10 @@ export const useProformaDetailPageController = () => { id, enabled: Boolean(id), }); + const issuanceReadinessQuery = useProformaIssuanceReadinessQuery({ + id, + enabled: Boolean(id), + }); const issueDialogCtrl = useIssueProformaDialogController(); const changeStatusDialogCtrl = useChangeProformaStatusDialogController(); @@ -125,6 +132,7 @@ export const useProformaDetailPageController = () => { }, [paymentMethodsQuery.data?.items]); const proforma = proformaQuery.data; + const issuanceReadiness = issuanceReadinessQuery.data; const paymentMethodLabel = paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ?? null; @@ -397,6 +405,32 @@ export const useProformaDetailPageController = () => { ? buildProformaDetailGeneralViewModel(proforma, paymentContext) : null; + const issuanceReadinessViewModel: ProformaDetailIssuanceReadinessViewModel | null = + issuanceReadinessQuery.isError && issuanceReadinessQuery.error instanceof Error + ? { + title: t("proformas.issuance_readiness.load_error_title"), + description: issuanceReadinessQuery.error.message, + blockers: [], + retryLabel: t("proformas.issuance_readiness.retry"), + onRetry: () => { + issuanceReadinessQuery.refetch().catch((error: unknown) => { + showErrorToast( + t("proformas.issuance_readiness.load_error_title"), + error instanceof Error + ? error.message + : t("proformas.issuance_readiness.load_error_description") + ); + }); + }, + } + : issuanceReadiness && !issuanceReadiness.canIssue + ? { + title: t("proformas.issuance_readiness.blocked_title"), + description: t("proformas.issuance_readiness.detail_description"), + blockers: getProformaIssuanceBlockerMessages(issuanceReadiness, t), + } + : null; + const actions: ProformaDetailActionsViewModel = useMemo(() => { if (!proforma) { return { @@ -407,6 +441,7 @@ export const useProformaDetailPageController = () => { return buildProformaDetailActions({ status: proforma.status, isArchived: proforma.isArchived, + canIssue: issuanceReadiness?.canIssue ?? false, labels: { edit: t("pages.proformas.list.actions.edit"), changeStatus: t("pages.proformas.list.actions.change_status"), @@ -423,7 +458,7 @@ export const useProformaDetailPageController = () => { editArchived: t("proformas.detail.actions.disabled_reasons.edit_archived"), statusIssued: t("pages.proformas.list.actions.disabled_reasons.status_issued"), statusArchived: t("proformas.detail.actions.disabled_reasons.status_archived"), - issueNotAllowed: t("proformas.issue_proforma_dialog.not_allowed_description"), + issueNotAllowed: t("proformas.issuance_readiness.detail_description"), operationInProgress: t( "pages.proformas.list.actions.disabled_reasons.operation_in_progress" ), @@ -445,6 +480,7 @@ export const useProformaDetailPageController = () => { previewPdfBlob, previewPdfQuery.isFetching, proforma, + issuanceReadiness?.canIssue, t, unarchiveMutation.isPending, ]); @@ -499,6 +535,7 @@ export const useProformaDetailPageController = () => { header, document, general, + issuanceReadiness: issuanceReadinessViewModel, actions, navigation: { currentReturnTo, diff --git a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts index 71bf2cb8..b698c483 100644 --- a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts @@ -155,3 +155,11 @@ export interface ProformaDetailActionViewModel { export interface ProformaDetailActionsViewModel { items: ProformaDetailActionViewModel[]; } + +export interface ProformaDetailIssuanceReadinessViewModel { + title: string; + description: string; + blockers: string[]; + retryLabel?: string; + onRetry?: () => void; +} diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx index cc1b8808..c524a842 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-inspector.tsx @@ -1,18 +1,23 @@ import { RdxContextPanel } from "@repo/rdx-ui/components"; import { useTranslation } from "../../../../i18n"; -import type { ProformaDetailGeneralViewModel } from "../../entities"; +import type { + ProformaDetailGeneralViewModel, + ProformaDetailIssuanceReadinessViewModel, +} from "../../entities"; import { ProformaGeneralPanel } from "./proforma-general-panel"; type ProformaDetailInspectorProps = { general: ProformaDetailGeneralViewModel; + issuanceReadiness: ProformaDetailIssuanceReadinessViewModel | null; onOpenCustomer: () => void; onOpenLinkedInvoice: () => void; }; export const ProformaDetailInspector = ({ general, + issuanceReadiness, onOpenCustomer, onOpenLinkedInvoice, }: ProformaDetailInspectorProps) => { @@ -26,6 +31,7 @@ export const ProformaDetailInspector = ({ > diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx index 7d1f2b9b..08236049 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-general-panel.tsx @@ -2,11 +2,16 @@ import { Button } from "@repo/shadcn-ui/components"; import { ExternalLinkIcon } from "lucide-react"; import { useTranslation } from "../../../../i18n"; -import type { ProformaDetailGeneralViewModel } from "../../entities"; +import { ProformaIssuanceReadinessAlert } from "../../../shared"; +import type { + ProformaDetailGeneralViewModel, + ProformaDetailIssuanceReadinessViewModel, +} from "../../entities"; import { ProformaDetailField } from "../components"; export interface ProformaGeneralPanelProps { general: ProformaDetailGeneralViewModel; + issuanceReadiness: ProformaDetailIssuanceReadinessViewModel | null; onOpenCustomer: () => void; onOpenLinkedInvoice: () => void; } @@ -15,6 +20,7 @@ const EMPTY_VALUE = "\u2014"; export const ProformaGeneralPanel = ({ general, + issuanceReadiness, onOpenCustomer, onOpenLinkedInvoice, }: ProformaGeneralPanelProps) => { @@ -22,6 +28,16 @@ export const ProformaGeneralPanel = ({ return (
+ {issuanceReadiness ? ( + + ) : null} +

{general.summary.totalLabel} diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx index 03cb5cac..053b8480 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx @@ -35,11 +35,15 @@ export const ProformaDetailPage = () => { ); } + if (!(ctrl.header && ctrl.document && ctrl.general)) { + return null; + } + return (

@@ -47,7 +51,7 @@ export const ProformaDetailPage = () => { { } inspector={ @@ -65,14 +70,20 @@ export const ProformaDetailPage = () => { /> { if (!open) { ctrl.dialogs.issue.closeDialog(); } }} + onRetryReadiness={ctrl.dialogs.issue.retryReadiness} open={ctrl.dialogs.issue.open} + readinessError={ctrl.dialogs.issue.readinessError} target={ctrl.dialogs.issue.target} /> diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts index 8721207d..87b75608 100644 --- a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts @@ -30,6 +30,7 @@ export interface ProformaDetailActionLabels { export interface BuildProformaDetailActionsParams { status: ProformaStatus; isArchived: boolean; + canIssue: boolean; labels: ProformaDetailActionLabels; loading: Partial>; } @@ -66,12 +67,11 @@ const buildAction = ( export const buildProformaDetailActions = ({ status, isArchived, + canIssue, labels, loading, }: BuildProformaDetailActionsParams): ProformaDetailActionsViewModel => { const isDraft = status === "draft"; - const isSent = status === "sent"; - const isApproved = status === "approved"; const isRejected = status === "rejected"; const isIssued = status === "issued"; const canChangeStatus = hasManualTransition(status); @@ -80,7 +80,7 @@ export const buildProformaDetailActions = ({ buildAction( "edit", labels.edit, - isApproved ? "secondary" : "primary", + "secondary", "visible", !(isArchived || isIssued), loading.edit ?? false, @@ -107,10 +107,10 @@ export const buildProformaDetailActions = ({ "issue", labels.issue, "primary", - isApproved || isSent ? "visible" : "hidden", - isApproved && !isArchived, + "visible", + canIssue && !isArchived, loading.issue ?? false, - isApproved ? null : labels.disabledReasons.issueNotAllowed + canIssue ? null : labels.disabledReasons.issueNotAllowed ), buildAction( "duplicate", diff --git a/modules/customer-invoices/src/web/proformas/issue-proforma/controllers/use-issue-proforma-dialog.controller.ts b/modules/customer-invoices/src/web/proformas/issue-proforma/controllers/use-issue-proforma-dialog.controller.ts index 0458086b..cb692eb6 100644 --- a/modules/customer-invoices/src/web/proformas/issue-proforma/controllers/use-issue-proforma-dialog.controller.ts +++ b/modules/customer-invoices/src/web/proformas/issue-proforma/controllers/use-issue-proforma-dialog.controller.ts @@ -1,8 +1,13 @@ import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { useState } from "react"; +import { createSearchParams, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../i18n"; -import { useProformaIssueMutation } from "../../shared"; +import { + getProformaIssuanceBlockerMessages, + useProformaIssuanceReadinessQuery, + useProformaIssueMutation, +} from "../../shared"; import type { IssueProformaTarget } from "../entities"; interface IssueDialogState { @@ -10,10 +15,10 @@ interface IssueDialogState { target: IssueProformaTarget | null; } -const canIssueProforma = (target: IssueProformaTarget) => target.status === "approved"; - export const useIssueProformaDialogController = () => { const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); const issueMutation = useProformaIssueMutation(); const [state, setState] = useState({ @@ -21,15 +26,15 @@ export const useIssueProformaDialogController = () => { target: null, }); - const openDialog = (target: IssueProformaTarget) => { - if (!canIssueProforma(target)) { - showErrorToast( - t("proformas.issue_proforma_dialog.not_allowed_title"), - t("proformas.issue_proforma_dialog.not_allowed_description") - ); - return; - } + const readinessQuery = useProformaIssuanceReadinessQuery({ + id: state.target?.id, + enabled: state.open && Boolean(state.target?.id), + }); + const readiness = readinessQuery.data; + const blockerMessages = getProformaIssuanceBlockerMessages(readiness, t); + + const openDialog = (target: IssueProformaTarget) => { setState({ open: true, target, @@ -50,7 +55,7 @@ export const useIssueProformaDialogController = () => { return; } - if (!canIssueProforma(target)) { + if (!readiness?.canIssue) { showErrorToast( t("proformas.issue_proforma_dialog.not_allowed_title"), t("proformas.issue_proforma_dialog.not_allowed_description") @@ -73,6 +78,8 @@ export const useIssueProformaDialogController = () => { target: null, }); } catch (error) { + await readinessQuery.refetch(); + const message = error instanceof Error ? error.message : t("proformas.issue_proforma_dialog.unknown_error"); @@ -80,12 +87,41 @@ export const useIssueProformaDialogController = () => { } }; + const openEdit = () => { + const target = state.target; + + if (!target) { + return; + } + + const returnTo = `${location.pathname}${location.search}`; + + setState({ + open: false, + target: null, + }); + + navigate({ + pathname: `/proformas/${target.id}/edit`, + search: createSearchParams({ returnTo }).toString(), + }); + }; + return { open: state.open, target: state.target, isSubmitting: issueMutation.isPending, + readiness, + isReadinessLoading: readinessQuery.isLoading || readinessQuery.isFetching, + readinessError: + readinessQuery.isError && readinessQuery.error instanceof Error + ? readinessQuery.error.message + : null, + blockerMessages, openDialog, closeDialog, confirmIssue, + retryReadiness: readinessQuery.refetch, + openEdit, }; }; diff --git a/modules/customer-invoices/src/web/proformas/issue-proforma/ui/issue-proforma-dialog.tsx b/modules/customer-invoices/src/web/proformas/issue-proforma/ui/issue-proforma-dialog.tsx index f713169a..97d68654 100644 --- a/modules/customer-invoices/src/web/proformas/issue-proforma/ui/issue-proforma-dialog.tsx +++ b/modules/customer-invoices/src/web/proformas/issue-proforma/ui/issue-proforma-dialog.tsx @@ -10,6 +10,7 @@ import { } from "@repo/shadcn-ui/components"; import { useTranslation } from "../../../i18n"; +import { ProformaIssuanceReadinessAlert } from "../../shared"; import type { IssueProformaTarget } from "../entities"; interface IssueProformaDialogProps { @@ -17,7 +18,13 @@ interface IssueProformaDialogProps { onOpenChange: (open: boolean) => void; target: IssueProformaTarget | null; isSubmitting: boolean; + isReadinessLoading: boolean; + readinessError: string | null; + canIssue: boolean; + blockerMessages: string[]; onConfirm: () => void; + onRetryReadiness: () => void; + onEdit: () => void; } export const IssueProformaDialog = ({ @@ -25,24 +32,59 @@ export const IssueProformaDialog = ({ onOpenChange, target, isSubmitting, + isReadinessLoading, + readinessError, + canIssue, + blockerMessages, onConfirm, + onRetryReadiness, + onEdit, }: IssueProformaDialogProps) => { const { t } = useTranslation(); + const isBlocked = !(isReadinessLoading || readinessError || canIssue); return ( - {t("proformas.issue_proforma_dialog.title")} + + {isBlocked + ? t("proformas.issuance_readiness.dialog.blocked_title") + : t("proformas.issue_proforma_dialog.title")} + - - {t("proformas.issue_proforma_dialog.description", { - reference: target?.reference ?? "", - })} -
-
- {t("proformas.issue_proforma_dialog.warning")} -
+ {isReadinessLoading ? ( + + + {t("proformas.issuance_readiness.dialog.loading")} + + ) : readinessError ? ( + + ) : isBlocked ? ( + + ) : ( + + {t("proformas.issue_proforma_dialog.description", { + reference: target?.reference ?? "", + })} +
+
+ {t("proformas.issue_proforma_dialog.warning")} +
+ )}
@@ -50,16 +92,18 @@ export const IssueProformaDialog = ({ {t("proformas.issue_proforma_dialog.cancel")} - + {!(isReadinessLoading || readinessError) && canIssue ? ( + + ) : null}
diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-issuance-readiness.adapter.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-issuance-readiness.adapter.ts new file mode 100644 index 00000000..f6dac89f --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/get-proforma-issuance-readiness.adapter.ts @@ -0,0 +1,13 @@ +import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common"; +import type { ProformaIssuanceReadiness } from "../entities"; + +export const GetProformaIssuanceReadinessAdapter = { + fromDto(dto: GetProformaIssuanceReadinessResponseDTO): ProformaIssuanceReadiness { + return { + canIssue: dto.can_issue, + blockers: dto.blockers.map((blocker) => ({ + code: blocker.code, + })), + }; + }, +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts b/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts index fb52771a..c01d5344 100644 --- a/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/adapters/index.ts @@ -1,4 +1,5 @@ export * from "./get-proforma-by-id.adapter"; +export * from "./get-proforma-issuance-readiness.adapter"; export * from "./list-document-series.adapter"; export * from "./list-proformas.adapter"; export * from "./proforma-to-list-row-patch.adapter"; diff --git a/modules/customer-invoices/src/web/proformas/shared/api/get-proforma-issuance-readiness.api.ts b/modules/customer-invoices/src/web/proformas/shared/api/get-proforma-issuance-readiness.api.ts new file mode 100644 index 00000000..547fa26b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/api/get-proforma-issuance-readiness.api.ts @@ -0,0 +1,28 @@ +import type { IDataSource } from "@erp/core/client"; + +import type { GetProformaIssuanceReadinessResponseDTO } from "../../../../common"; + +export interface GetProformaIssuanceReadinessParams { + id: string; + signal?: AbortSignal; +} + +export type GetProformaIssuanceReadinessResult = GetProformaIssuanceReadinessResponseDTO; + +export const getProformaIssuanceReadiness = ( + dataSource: IDataSource, + params: GetProformaIssuanceReadinessParams +): Promise => { + const { id, signal } = params; + + if (!id) { + throw new Error("proformaId is required"); + } + + return dataSource.custom({ + path: `proformas/${id}/issuance-readiness`, + method: "get", + data: undefined, + signal, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/api/index.ts b/modules/customer-invoices/src/web/proformas/shared/api/index.ts index 7c26cbc6..b39d7741 100644 --- a/modules/customer-invoices/src/web/proformas/shared/api/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/api/index.ts @@ -4,6 +4,7 @@ export * from "./create-proforma.api"; export * from "./delete-proforma-by-id.api"; export * from "./duplicate-proforma-by-id.api"; export * from "./get-proforma-by-id.api"; +export * from "./get-proforma-issuance-readiness.api"; export * from "./issue-proforma-by-id.api"; export * from "./list-document-series.api"; export * from "./list-proformas-by-criteria.api"; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts index d5f41360..15a9ff04 100644 --- a/modules/customer-invoices/src/web/proformas/shared/entities/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/entities/index.ts @@ -5,6 +5,7 @@ export * from "./proforma.entity"; export * from "./proforma-item.entity"; export * from "./proforma-list.entity"; export * from "./proforma-list-row.entity"; +export * from "./proforma-issuance-readiness.entity"; export * from "./proforma-recipient.entity"; export * from "./proforma-status.entity"; export * from "./proforma-status-counts.entity"; diff --git a/modules/customer-invoices/src/web/proformas/shared/entities/proforma-issuance-readiness.entity.ts b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-issuance-readiness.entity.ts new file mode 100644 index 00000000..1fd486ba --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/entities/proforma-issuance-readiness.entity.ts @@ -0,0 +1,18 @@ +export type ProformaIssuanceBlockerCode = + | "already_issued" + | "invalid_status" + | "missing_description" + | "missing_operation_date" + | "incomplete_recipient" + | "missing_items" + | "missing_payment_method" + | "missing_tax_regime" + | "payment_method_unavailable" + | "payment_method_inactive"; + +export interface ProformaIssuanceReadiness { + canIssue: boolean; + blockers: Array<{ + code: ProformaIssuanceBlockerCode; + }>; +} diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts index 0cc12edd..2818baf4 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/index.ts @@ -7,6 +7,7 @@ export * from "./use-proforma-create-mutation"; export * from "./use-proforma-delete-mutation"; export * from "./use-proforma-get-query"; export * from "./use-proforma-issue-mutation"; +export * from "./use-proforma-issuance-readiness-query"; export * from "./use-proforma-unarchive-mutation"; export * from "./use-proforma-update-mutation"; export * from "./use-proformas-list-query"; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts index 2fb40c94..fe24f826 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/keys.ts @@ -38,6 +38,14 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [ { proformaId }, ]; +export const PROFORMAS_ISSUANCE_READINESS_QUERY_KEY_PREFIX = [ + "proformas:issuance-readiness", +] as const; +export const PROFORMA_ISSUANCE_READINESS_QUERY_KEY = (proformaId?: string): QueryKey => [ + ...PROFORMAS_ISSUANCE_READINESS_QUERY_KEY_PREFIX, + { proformaId }, +]; + export const PROFORMAS_PDF_QUERY_KEY_PREFIX = ["proformas:pdf"] as const; export const PROFORMA_PDF_QUERY_KEY = (proformaId?: string): QueryKey => [ ...PROFORMAS_PDF_QUERY_KEY_PREFIX, diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts index 710f1f24..f17075f6 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/proforma-cache-strategy.ts @@ -6,8 +6,9 @@ import type { Proforma, ProformaList, ProformaListRow } from "../entities"; import { LIST_PROFORMAS_QUERY_KEY_PREFIX, - PROFORMA_PDF_QUERY_KEY, PROFORMAS_PDF_QUERY_KEY_PREFIX, + PROFORMA_ISSUANCE_READINESS_QUERY_KEY, + PROFORMA_PDF_QUERY_KEY, PROFORMA_QUERY_KEY, } from "./keys"; @@ -20,6 +21,10 @@ export interface DeleteProformaCacheContext { snapshots: ProformaListCacheSnapshot[]; } +const hasListItems = (page: ProformaList | undefined): page is ProformaList => { + return Array.isArray(page?.items); +}; + // Primitivas export function cancelProformaListQueries(queryClient: QueryClient) { @@ -54,6 +59,16 @@ export function invalidateProformaPdfQuery(queryClient: QueryClient, proformaId: }); } +export function invalidateProformaIssuanceReadinessQuery( + queryClient: QueryClient, + proformaId: string +) { + return queryClient.invalidateQueries({ + queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(proformaId), + exact: true, + }); +} + export function removeProformaPdfQuery(queryClient: QueryClient, proformaId: string) { return queryClient.removeQueries({ queryKey: PROFORMA_PDF_QUERY_KEY(proformaId), @@ -100,7 +115,7 @@ export function upsertProformaInListCaches( for (const key of keys) { const page = queryClient.getQueryData(key); - if (!page) continue; + if (!hasListItems(page)) continue; const index = page.items.findIndex((row) => row.id === proforma.id); if (index === -1) continue; @@ -130,7 +145,7 @@ export function removeProformaFromListCaches( })); for (const { key, page } of snapshots) { - if (!page) continue; + if (!hasListItems(page)) continue; queryClient.setQueryData(key, { ...page, @@ -166,6 +181,7 @@ export function syncUpdatedProformaCaches(queryClient: QueryClient, proforma: Pr return Promise.all([ invalidateProformaListQueries(queryClient), + invalidateProformaIssuanceReadinessQuery(queryClient, proforma.id), invalidateProformaPdfQuery(queryClient, proforma.id), ]); } @@ -224,6 +240,7 @@ export async function syncChangedProformaStatusCaches( await Promise.all([ invalidateProformaListQueries(queryClient), + invalidateProformaIssuanceReadinessQuery(queryClient, proformaId), invalidateProformaPdfQuery(queryClient, proformaId), ]); } @@ -242,6 +259,7 @@ export async function syncIssuedProformaCaches( await Promise.all([ invalidateProformaListQueries(queryClient), + invalidateProformaIssuanceReadinessQuery(queryClient, proformaId), invalidateIssuedInvoiceListQueries(queryClient), invalidateProformaPdfQuery(queryClient, proformaId), ]); diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts new file mode 100644 index 00000000..0f7810d6 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-issuance-readiness-query.ts @@ -0,0 +1,38 @@ +import { useDataSource } from "@erp/core/hooks"; +import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { GetProformaIssuanceReadinessAdapter } from "../adapters"; +import { getProformaIssuanceReadiness } from "../api"; +import type { ProformaIssuanceReadiness } from "../entities"; + +import { PROFORMA_ISSUANCE_READINESS_QUERY_KEY } from "./keys"; + +export interface UseProformaIssuanceReadinessQueryOptions { + id?: string; + enabled?: boolean; +} + +export const useProformaIssuanceReadinessQuery = ( + options: UseProformaIssuanceReadinessQueryOptions +): UseQueryResult => { + const dataSource = useDataSource(); + const id = options.id; + const enabled = options.enabled ?? Boolean(id); + + return useQuery({ + queryKey: PROFORMA_ISSUANCE_READINESS_QUERY_KEY(id), + queryFn: async ({ signal }) => { + if (!id) { + throw new Error("proformaId is required"); + } + + const dto = await getProformaIssuanceReadiness(dataSource, { + id, + signal, + }); + + return GetProformaIssuanceReadinessAdapter.fromDto(dto); + }, + enabled, + }); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts index ad176a15..66511a07 100644 --- a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts @@ -1,2 +1,3 @@ export * from "./proforma-layout"; +export * from "./proforma-issuance-readiness-alert"; export * from "./proforma-page-header"; diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-issuance-readiness-alert.tsx b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-issuance-readiness-alert.tsx new file mode 100644 index 00000000..ae855814 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-issuance-readiness-alert.tsx @@ -0,0 +1,57 @@ +import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; +import { AlertCircleIcon, RefreshCwIcon } from "lucide-react"; + +export interface ProformaIssuanceReadinessAlertProps { + title: string; + description?: string; + blockers?: string[]; + retryLabel?: string; + onRetry?: () => void; + actionLabel?: string; + onAction?: () => void; +} + +export const ProformaIssuanceReadinessAlert = ({ + title, + description, + blockers = [], + retryLabel, + onRetry, + actionLabel, + onAction, +}: ProformaIssuanceReadinessAlertProps) => { + return ( + + + {title} + + {description ?

{description}

: null} + + {blockers.length > 0 ? ( +
    + {blockers.map((blocker) => ( +
  • {blocker}
  • + ))} +
+ ) : null} + + {onRetry || onAction ? ( +
+ {onRetry ? ( + + ) : null} + + {onAction ? ( + + ) : null} +
+ ) : null} +
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts index d9a21bf8..29a063b4 100644 --- a/modules/customer-invoices/src/web/proformas/shared/utils/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/utils/index.ts @@ -3,3 +3,4 @@ export * from "./build-proforma-payment-term-select-items"; export * from "./calculate-proforma-due-dates"; export * from "./ensure-current-value-in-select-items"; export * from "./proforma-fiscal-options.utils"; +export * from "./proforma-issuance-readiness"; diff --git a/modules/customer-invoices/src/web/proformas/shared/utils/proforma-issuance-readiness.ts b/modules/customer-invoices/src/web/proformas/shared/utils/proforma-issuance-readiness.ts new file mode 100644 index 00000000..36c02bac --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/utils/proforma-issuance-readiness.ts @@ -0,0 +1,95 @@ +import type { ProformaIssuanceBlockerCode, ProformaIssuanceReadiness } from "../entities"; + +const BLOCKER_TRANSLATION_KEYS: Record = { + already_issued: "proformas.issuance_readiness.blockers.already_issued", + invalid_status: "proformas.issuance_readiness.blockers.invalid_status", + missing_description: "proformas.issuance_readiness.blockers.missing_description", + missing_operation_date: "proformas.issuance_readiness.blockers.missing_operation_date", + incomplete_recipient: "proformas.issuance_readiness.blockers.incomplete_recipient", + missing_items: "proformas.issuance_readiness.blockers.missing_items", + missing_payment_method: "proformas.issuance_readiness.blockers.missing_payment_method", + missing_tax_regime: "proformas.issuance_readiness.blockers.missing_tax_regime", + payment_method_unavailable: "proformas.issuance_readiness.blockers.payment_method_unavailable", + payment_method_inactive: "proformas.issuance_readiness.blockers.payment_method_inactive", +}; + +const BLOCKER_TITLE_TRANSLATION_KEYS: Record = { + already_issued: "proformas.issuance_readiness.blocker_details.already_issued.title", + invalid_status: "proformas.issuance_readiness.blocker_details.invalid_status.title", + missing_description: "proformas.issuance_readiness.blocker_details.missing_description.title", + missing_operation_date: + "proformas.issuance_readiness.blocker_details.missing_operation_date.title", + incomplete_recipient: "proformas.issuance_readiness.blocker_details.incomplete_recipient.title", + missing_items: "proformas.issuance_readiness.blocker_details.missing_items.title", + missing_payment_method: + "proformas.issuance_readiness.blocker_details.missing_payment_method.title", + missing_tax_regime: "proformas.issuance_readiness.blocker_details.missing_tax_regime.title", + payment_method_unavailable: + "proformas.issuance_readiness.blocker_details.payment_method_unavailable.title", + payment_method_inactive: + "proformas.issuance_readiness.blocker_details.payment_method_inactive.title", +}; + +const BLOCKER_DESCRIPTION_TRANSLATION_KEYS: Record = { + already_issued: "proformas.issuance_readiness.blocker_details.already_issued.description", + invalid_status: "proformas.issuance_readiness.blocker_details.invalid_status.description", + missing_description: + "proformas.issuance_readiness.blocker_details.missing_description.description", + missing_operation_date: + "proformas.issuance_readiness.blocker_details.missing_operation_date.description", + incomplete_recipient: + "proformas.issuance_readiness.blocker_details.incomplete_recipient.description", + missing_items: "proformas.issuance_readiness.blocker_details.missing_items.description", + missing_payment_method: + "proformas.issuance_readiness.blocker_details.missing_payment_method.description", + missing_tax_regime: "proformas.issuance_readiness.blocker_details.missing_tax_regime.description", + payment_method_unavailable: + "proformas.issuance_readiness.blocker_details.payment_method_unavailable.description", + payment_method_inactive: + "proformas.issuance_readiness.blocker_details.payment_method_inactive.description", +}; + +export type ProformaIssuanceTranslate = ( + key: string, + params?: Record +) => string; + +export interface ProformaIssuanceBlockerDetail { + code: ProformaIssuanceBlockerCode; + title: string; + description: string; + message: string; +} + +export const getProformaIssuanceBlockerTranslationKey = (code: ProformaIssuanceBlockerCode) => { + return BLOCKER_TRANSLATION_KEYS[code]; +}; + +export const getProformaIssuanceBlockerMessages = ( + readiness: ProformaIssuanceReadiness | undefined, + translate: ProformaIssuanceTranslate +): string[] => { + if (!readiness) { + return []; + } + + return readiness.blockers.map((blocker) => + translate(getProformaIssuanceBlockerTranslationKey(blocker.code)) + ); +}; + +export const getProformaIssuanceBlockerDetails = ( + readiness: ProformaIssuanceReadiness | undefined, + translate: ProformaIssuanceTranslate +): ProformaIssuanceBlockerDetail[] => { + if (!readiness) { + return []; + } + + return readiness.blockers.map((blocker) => ({ + code: blocker.code, + title: translate(BLOCKER_TITLE_TRANSLATION_KEYS[blocker.code]), + description: translate(BLOCKER_DESCRIPTION_TRANSLATION_KEYS[blocker.code]), + message: translate(getProformaIssuanceBlockerTranslationKey(blocker.code)), + })); +}; diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts index fc32fb77..c9bdd7bc 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-controller.ts @@ -118,7 +118,6 @@ export const useUpdateProformaController = ( useEffect(() => { if (!proformaData) return; - console.log("Reseteando form con datos de la proforma:", proformaData); form.reset(mapProformaToProformaUpdateForm(proformaData), { keepDirty: false, // <-- importante: no marca el form como "dirty" al cargar los datos reales }); @@ -224,7 +223,6 @@ export const useUpdateProformaController = ( options?.onUpdated?.(updated); } catch (error: unknown) { - console.log(error); const normalizedError = normalizeSubmitError(error); // No revierto el form para que no se pierdan los cambios que @@ -242,8 +240,6 @@ export const useUpdateProformaController = ( if (isValidationErrorCollection(normalizedError)) { applyValidationErrorCollection(form, normalizedError); - console.log("Errores de validación aplicados al form:", form.formState.errors); - focusFirstInputFormError(form.formState.errors, form); if (options?.errorToasts !== false) { diff --git a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts index 9ac26072..1655a999 100644 --- a/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/update/controllers/use-update-proforma-page-controller.ts @@ -4,6 +4,10 @@ import { useCallback, useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useTranslation } from "../../../i18n"; +import { + getProformaIssuanceBlockerMessages, + useProformaIssuanceReadinessQuery, +} from "../../shared"; import { buildProformaUpdateMissingEntityPageErrorViewModel, buildProformaUpdatePageErrorViewModel, @@ -20,6 +24,10 @@ export const useUpdateProformaPageController = () => { const [searchParams] = useSearchParams(); const updateCtrl = useUpdateProformaController(proformaId); + const issuanceReadinessQuery = useProformaIssuanceReadinessQuery({ + id: proformaId, + enabled: Boolean(proformaId), + }); const returnTo = searchParams.get("returnTo") ?? "/proformas"; const feedbackState = resolveProformaUpdatePageFeedbackState({ @@ -70,6 +78,11 @@ export const useUpdateProformaPageController = () => { return null; }, [feedbackState.hasBlockingError, feedbackState.hasDataInconsistency, t, updateCtrl.loadError]); + const issuanceReadinessMessages = getProformaIssuanceBlockerMessages( + issuanceReadinessQuery.data, + t + ); + return { updateCtrl, customerChangeCtrl, @@ -81,5 +94,13 @@ export const useUpdateProformaPageController = () => { goBackToList, isRetrying: updateCtrl.isFetching && !updateCtrl.isLoading, isInitialLoading: feedbackState.isInitialLoading, + issuanceReadiness: issuanceReadinessQuery.data, + isIssuanceReadinessLoading: issuanceReadinessQuery.isLoading && !issuanceReadinessQuery.data, + issuanceReadinessError: + issuanceReadinessQuery.isError && issuanceReadinessQuery.error instanceof Error + ? issuanceReadinessQuery.error.message + : null, + retryIssuanceReadiness: issuanceReadinessQuery.refetch, + issuanceReadinessMessages, }; }; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx index 0c2a7ddb..2274c0eb 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/line-editor.tsx @@ -35,6 +35,7 @@ export interface LineEditorColumn { export interface LineEditorProps { title: React.ReactNode; + description?: React.ReactNode; lines: TLine[]; getLineKey: (line: TLine, index: number) => React.Key; @@ -69,6 +70,7 @@ export interface LineEditorProps { export const LineEditor = ({ title, + description, lines, getLineKey, @@ -103,8 +105,11 @@ export const LineEditor = ({ }: LineEditorProps) => { return (
-
-

{title}

+
+
+

{title}

+ {description ?

{description}

: null} +
+ ), + Badge: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => ( + {children} + ), + Popover: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverDescription: ({ children }: { children: ReactNode }) =>

{children}

, + PopoverHeader: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverTitle: ({ children }: { children: ReactNode }) =>

{children}

, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + Separator: () =>
, + }, + }); + + mock.module("lucide-react", { + namedExports: { + AlertCircleIcon: () => null, + CheckCircle2Icon: () => null, + CircleDashedIcon: () => null, + CircleHelpIcon: () => null, + }, + }); + + mock.module("../../../../i18n", { + namedExports: { + useTranslation: () => ({ + t: translate, + }), + }, + }); + + mock.module("../../../shared", { + namedExports: { + getProformaIssuanceBlockerDetails: () => blockerDetails, + }, + }); + + const { ProformaUpdateHelpPopover } = await import("./proforma-update-help-popover.tsx"); + + const blockedMarkup = renderToStaticMarkup( + + ); + + assert.match(blockedMarkup, /Información de la proforma\. 2 requisitos pendientes de emisión\./); + assert.match(blockedMarkup, /Estado para emisión/); + assert.match(blockedMarkup, />2 + ); + + assert.match(issuableMarkup, /Información y estado de emisión de la proforma/); + assert.match(issuableMarkup, /Lista para emitir/); + assert.doesNotMatch(issuableMarkup, />0 + ); + assert.match(loadingMarkup, /Comprobando requisitos de emisión…/); + assert.match(loadingMarkup, /Información y estado de emisión de la proforma/); + assert.doesNotMatch(loadingMarkup, />\d+ undefined} readinessError="network error" /> + ); + assert.match(errorMarkup, /No se ha podido comprobar el estado de emisión\./); + assert.match(errorMarkup, /Reintentar/); + assert.match(errorMarkup, /Información y estado de emisión de la proforma/); + assert.doesNotMatch(errorMarkup, />\d+ void; +} + +interface HelpTriggerProps { + ariaLabel: string; + pendingBlockersCount: number; +} + +interface HelpReadinessSectionProps { + blockers: ReturnType; + readiness?: ProformaIssuanceReadiness; + readinessError?: string | null; + isReadinessLoading: boolean; + savedStateHint: string; + onRetryReadiness?: () => void; + summaryKey: string; + translate: (key: string, params?: Record) => string; +} + +const HelpTrigger = ({ ariaLabel, pendingBlockersCount }: HelpTriggerProps) => { + return ( + + ); +}; + +const HelpReadinessSection = ({ + blockers, + readiness, + readinessError, + isReadinessLoading, + savedStateHint, + onRetryReadiness, + summaryKey, + translate, +}: HelpReadinessSectionProps) => { + if (isReadinessLoading) { + return ( +
+
+ ); + } + + if (readinessError) { + return ( +
+
+
+ + {onRetryReadiness ? ( +
+ +
+ ) : null} +
+ ); + } + + if (readiness?.canIssue) { + return ( +
+
+
+ + {savedStateHint} +
+ ); + } + + if (readiness) { + return ( +
+
+

+ {translate(summaryKey, { count: blockers.length })} +

+ {savedStateHint} +
+ +
+ {blockers.map((blocker) => ( +
+

{blocker.title}

+

{blocker.description}

+
+ ))} +
+
+ ); + } + + return ( +
+

+ {translate("proformas.issuance_readiness.update.loading")} +

+
+ ); +}; + +export const ProformaUpdateHelpPopover = ({ + readiness, + readinessError, + isReadinessLoading = false, + isDirty = false, + onRetryReadiness, +}: ProformaUpdateHelpPopoverProps) => { + const { t } = useTranslation(); + const blockers = getProformaIssuanceBlockerDetails(readiness, t); + const pendingBlockersCount = + !(isReadinessLoading || readinessError) && readiness && !readiness.canIssue + ? readiness.blockers.length + : 0; + const summaryKey = + blockers.length === 1 + ? "proformas.issuance_readiness.update.summary_one" + : "proformas.issuance_readiness.update.summary_other"; + const blockersAriaLabelKey = + pendingBlockersCount === 1 + ? "pages.proformas.update.header.help.aria_label_with_blockers_one" + : "pages.proformas.update.header.help.aria_label_with_blockers_other"; + const triggerAriaLabel = + pendingBlockersCount > 0 + ? t(blockersAriaLabelKey, { count: pendingBlockersCount }) + : t("pages.proformas.update.header.help.aria_label"); + const savedStateHint = isDirty + ? t("proformas.issuance_readiness.update.dirty_state_hint") + : t("proformas.issuance_readiness.update.saved_state_hint"); + + return ( + + + + + + + + {t("pages.proformas.update.header.help.title")} + + +
+
+
+

+ {t("pages.proformas.update.header.help.issuance_section_title")} +

+ +
+ + +
+ +
+
+

+ {t("pages.proformas.update.header.help.fiscal_section_title")} +

+ +
+ + + {t("pages.proformas.update.header.help.description")} + +
+
+
+
+ ); +}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx index 3ec2446f..e1c9f50c 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/editors/proforma-update-editor-form.test.tsx @@ -138,6 +138,9 @@ test("renderiza el patrón complejo con main, aside y un único formulario", () assert.match(html, /Configuración fiscal/); assert.match(html, /Condiciones de pago/); assert.match(html, /Líneas de detalle/); + assert.match(html, /Detalle de la proforma/); + assert.match(html, /Gestiona las líneas de productos o servicios de esta proforma/); assert.doesNotMatch(html, /max-w-5xl/); assert.ok(html.indexOf("Líneas de detalle") < html.indexOf(" | undefined; + let capturedHeaderProps: Record | undefined; let capturedSelectDialogProps: Record | undefined; let capturedQuickCreateDialogProps: Record | undefined; @@ -69,6 +70,12 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb }, }); + mock.module("../../../shared", { + namedExports: { + ProformaIssuanceReadinessAlert: () => null, + }, + }); + mock.module("../../controllers/use-update-proforma-page-controller", { namedExports: { useUpdateProformaPageController: () => ({ @@ -114,6 +121,10 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb totals: {}, }, currencyCode: "EUR", + proformaData: { + proformaReference: "PF-0088", + status: "draft", + }, }, customerChangeCtrl: { open: false, @@ -139,6 +150,13 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb goBackToList: () => undefined, isRetrying: false, isInitialLoading: false, + issuanceReadiness: { + canIssue: false, + blockers: [{ code: "missing_description" }], + }, + isIssuanceReadinessLoading: false, + issuanceReadinessError: null, + retryIssuanceReadiness: async () => undefined, }), }, }); @@ -146,14 +164,17 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb mock.module("../blocks", { namedExports: { ProformaCompactTotals: () => null, - ProformaUpdateHeader: ({ children }: { children: ReactNode }) => children, + ProformaUpdateHeader: (props: Record) => { + capturedHeaderProps = props; + return (props.children as ReactNode) ?? null; + }, }, }); mock.module("../components", { namedExports: { - ProformaInfoAlert: ({ children }: { children: ReactNode }) => children, ProformaUpdateCustomerChangeDialog: () => null, + ProformaUpdateHelpPopover: () => null, ProformaUpdateSkeleton: () => null, }, }); @@ -174,6 +195,7 @@ test("conecta la alta rápida directa y la alta rápida desde selector con callb assert.equal(capturedEditorProps?.onChangeCustomerClick, openSelectDialog); assert.equal(capturedEditorProps?.onCreateCustomerClick, openQuickCreateDialog); assert.equal(typeof capturedEditorProps?.onPaymentTermChange, "function"); + assert.ok(capturedHeaderProps?.helpAction); assert.equal(capturedSelectDialogProps?.onCreateNewCustomerClick, openQuickCreateFromSelection); assert.equal(capturedQuickCreateDialogProps?.ctrl, quickCreateCtrl); diff --git a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx index 77ec0c45..ce081aa1 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/pages/proforma-update-page.tsx @@ -8,14 +8,17 @@ import { RdxEditorStickyFooter, RhfSubmitActionButton, } from "@repo/rdx-ui/components"; +import { showErrorToast } from "@repo/rdx-ui/helpers"; +import { Badge } from "@repo/shadcn-ui/components"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "../../../../i18n"; +import { ProformaStatusBadge } from "../../../list/ui/components"; import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller"; import { ProformaCompactTotals, ProformaUpdateHeader } from "../blocks"; import { - ProformaInfoAlert, ProformaUpdateCustomerChangeDialog, + ProformaUpdateHelpPopover, ProformaUpdateSkeleton, } from "../components"; import { ProformaUpdateEditorForm } from "../editors"; @@ -25,12 +28,23 @@ export const ProformaUpdatePage = () => { const ctrl = useUpdateProformaPageController(); const { updateCtrl, customerChangeCtrl, selectCustomerCtrl, returnTo } = ctrl; + const proforma = updateCtrl.proformaData; const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo, }); const handleCancel = () => navigateBack(); + const handleRetryIssuanceReadiness = () => { + ctrl.retryIssuanceReadiness().catch((error: unknown) => { + showErrorToast( + t("proformas.issuance_readiness.load_error_title"), + error instanceof Error + ? error.message + : t("proformas.issuance_readiness.load_error_description") + ); + }); + }; if (ctrl.isInitialLoading) { return ; @@ -85,9 +99,28 @@ export const ProformaUpdatePage = () => { + } isSaving={updateCtrl.form.formState.isSubmitting} labels={{ - title: "Editar proforma", + title: proforma ? ( + + {t("pages.proformas.update.title", "Editar proforma")} + + {proforma.proformaReference} + + + + ) : ( + t("pages.proformas.update.title", "Editar proforma") + ), back: "Volver", modified: "Modificada", cancel: "Cancelar", @@ -103,13 +136,7 @@ export const ProformaUpdatePage = () => { //onDelete={openDeleteDialog} //onDuplicate={handleDuplicate} //onExportPdf={handleExportPdf} - > - {/* Alerta informativa */} - - Importante: Esta proforma no tiene validez - fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente. - - + />
diff --git a/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-selection-list.tsx b/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-selection-list.tsx index 1a606c31..b066ee89 100644 --- a/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-selection-list.tsx +++ b/modules/customers/src/common/features/customer-selection/ui/dialogs/select-customer-selection-list.tsx @@ -9,7 +9,6 @@ import { } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import { useTranslation } from "../../../../../web/i18n"; import type { CustomerSelectionOption } from "../../entities"; import { SelectCustomerEmptyCard } from "./select-customer-empty-card"; @@ -27,10 +26,7 @@ export const SelectCustomerSelectionList = ({ selectedCustomer, onSelect, }: SelectCustomerSelectionListProps) => { - const { t } = useTranslation(); - //const columns = useCustomersGridColumns({}); - console.log(selectedCustomer); if (isLoading) { return (