feat: enhance issued invoice detail sections with new components and data structures

- Updated GetIssuedInvoiceByIdResult to include verifactu as nullable.
- Introduced new fiscal classification and verifactu policy interfaces in issued-invoice.entity.ts.
- Added new UI components for displaying issued invoice details, including sections for document, fiscal, items, payment, recipient, summary, taxes, totals, and verifactu.
- Implemented skeleton loading state for issued invoice detail page.
- Created tests for issued invoice detail page to ensure proper rendering and error handling.
- Updated pnpm workspace configuration to disallow builds for specific packages.
This commit is contained in:
David Arranz 2026-08-19 12:04:31 +02:00
parent 7becfd31c0
commit 9c1f98df0c
27 changed files with 1668 additions and 78 deletions

View File

@ -796,48 +796,108 @@
}
},
"sections": {
"summary": "Summary",
"customer": "Customer",
"document": "Document details",
"commercial": "Commercial terms",
"fiscal": "Tax",
"items": "Line items",
"taxes": "Taxes",
"totals": "Totals",
"verifactu": "VeriFactu",
"linked_proforma": "Linked proforma",
"content": "Content"
},
"section_descriptions": {
"summary": "Identity and primary data of the issued document.",
"customer": "Historical recipient snapshot materialized in the invoice.",
"document": "Document metadata and navigation to the source proforma when available.",
"commercial": "Payment method, payment term and due rules preserved in the snapshot.",
"fiscal": "Historical tax data available in the issued invoice.",
"items": "Materialized line items from the document, without frontend recalculation.",
"taxes": "Materialized tax breakdown returned by the API.",
"totals": "Historical totals of the issued invoice.",
"verifactu": "Current VeriFactu state from the singular block available today."
},
"fields": {
"invoice_reference": "Invoice number",
"invoice_number": "Sequential number",
"series": "Series",
"status": "Status",
"invoice_date": "Invoice date",
"operation_date": "Operation date",
"line_count": "Lines",
"total_units": "Units",
"currency": "Currency",
"total_amount": "Total amount",
"customer_name": "Customer",
"customer_tin": "TIN",
"address": "Address",
"payment_method": "Payment method",
"payment_method_description": "Payment method description",
"payment_term": "Payment term",
"payment_term_description": "Payment term description",
"payment_term_due_rules": "Due rules",
"language": "Language",
"internal_reference": "Internal reference",
"tax_regime": "Tax regime",
"fiscal_tax_regime": "Historical tax regime",
"operation_qualification": "Operation qualification",
"exemption_reason": "Exemption reason",
"verifactu_profile_status": "VeriFactu status",
"verifactu_policy": "VeriFactu policy",
"verifactu_effective_at": "VeriFactu effective date",
"subtotal_amount": "Subtotal",
"items_discount_amount": "Item discount",
"global_discount_amount": "Global discount",
"discount_amount": "Discount",
"taxable_amount": "Taxable amount",
"tax_amount": "Tax amount",
"taxes_amount": "Total taxes",
"rec_amount": "Equivalence surcharge",
"retention_amount": "Withholding",
"tax_breakdown": "Tax breakdown",
"verifactu_status": "VeriFactu status",
"verifactu_url": "VeriFactu URL",
"verifactu_qr": "VeriFactu QR",
"linked_proforma": "Linked proforma",
"description": "Description",
"notes": "Notes"
"notes": "Notes",
"quantity": "Quantity",
"unit_amount": "Unit price",
"item_discount": "Line discount",
"item_taxes": "Line taxes"
},
"empty": {
"not_available": "—",
"no_address": "No address recorded.",
"no_description": "No description.",
"no_taxes": "No taxes reported."
},
"actions": {
"back": "Back",
"open_linked_proforma": "Open linked proforma",
"operation_in_progress": "The operation is in progress."
},
"payment_term": {
"due_days": "{{days}} days"
},
"items": {
"position": "Line {{position}}"
},
"verifactu": {
"empty": "This invoice has no VeriFactu block available in the current detail view.",
"qr_alt": "VeriFactu QR code",
"available": "Available",
"uses_verifactu": "Uses VeriFactu",
"does_not_use_verifactu": "Does not use VeriFactu",
"policy_status": {
"not_configured": "Not configured",
"exempt": "Exempt or not applicable",
"obligated_without_verifactu": "Obligated without VeriFactu",
"obligated_with_verifactu": "Obligated with VeriFactu"
}
},
"fiscal": {
"unknown_tax_code": "Tax"
}

View File

@ -707,13 +707,13 @@
}
},
"grid_columns": {
"series_invoice_number": "#",
"invoice_reference": "Num.",
"verifactu_status": "Estado",
"verifactu_qr_code": "QR",
"verifactu_url": "URL",
"invoice_number": "Nº factura",
"series": "Serie",
"reference": "Reference",
"reference": "Referencia",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"recipient": "Cliente",
@ -797,48 +797,108 @@
}
},
"sections": {
"summary": "Resumen",
"customer": "Cliente",
"document": "Datos del documento",
"commercial": "Condiciones comerciales",
"fiscal": "Fiscalidad",
"items": "Líneas",
"taxes": "Impuestos",
"totals": "Totales",
"verifactu": "VeriFactu",
"linked_proforma": "Proforma vinculada",
"content": "Contenido"
},
"section_descriptions": {
"summary": "Identidad y datos principales del documento emitido.",
"customer": "Snapshot histórico del destinatario materializado en la factura.",
"document": "Metadatos del documento y navegación a la proforma origen cuando existe.",
"commercial": "Forma, plazo y reglas de pago preservadas en el snapshot.",
"fiscal": "Datos fiscales históricos disponibles en la factura emitida.",
"items": "Líneas materializadas del documento, sin recálculo en frontend.",
"taxes": "Desglose fiscal materializado recibido desde la API.",
"totals": "Totales históricos de la factura emitida.",
"verifactu": "Estado actual de VeriFactu según el bloque singular disponible hoy."
},
"fields": {
"invoice_reference": "Número de factura",
"invoice_number": "Número correlativo",
"series": "Serie",
"status": "Estado",
"invoice_date": "Fecha de factura",
"operation_date": "Fecha de operación",
"line_count": "Líneas",
"total_units": "Unidades",
"currency": "Moneda",
"total_amount": "Importe total",
"customer_name": "Cliente",
"customer_tin": "NIF",
"address": "Dirección",
"payment_method": "Método de pago",
"payment_method_description": "Descripción del método de pago",
"payment_term": "Plazo de pago",
"payment_term_description": "Descripción del plazo de pago",
"payment_term_due_rules": "Reglas de vencimiento",
"language": "Idioma",
"internal_reference": "Referencia interna",
"tax_regime": "Régimen fiscal",
"fiscal_tax_regime": "Régimen fiscal histórico",
"operation_qualification": "Calificación de operación",
"exemption_reason": "Motivo de exención",
"verifactu_profile_status": "Situación VeriFactu",
"verifactu_policy": "Política VeriFactu",
"verifactu_effective_at": "Fecha efectiva VeriFactu",
"items_discount_amount": "Descuento de líneas",
"global_discount_amount": "Descuento global",
"subtotal_amount": "Subtotal",
"discount_amount": "Descuento",
"taxable_amount": "Base imponible",
"tax_amount": "Impuestos",
"taxes_amount": "Total impuestos",
"rec_amount": "Recargo de equivalencia",
"retention_amount": "Retención",
"tax_breakdown": "Desglose fiscal",
"verifactu_status": "Estado VeriFactu",
"verifactu_url": "URL VeriFactu",
"verifactu_qr": "QR VeriFactu",
"linked_proforma": "Proforma vinculada",
"description": "Descripción",
"notes": "Notas"
"notes": "Notas",
"quantity": "Cantidad",
"unit_amount": "Precio unitario",
"item_discount": "Descuento de línea",
"item_taxes": "Impuestos de línea"
},
"empty": {
"not_available": "—",
"no_address": "Sin dirección registrada.",
"no_description": "Sin descripción.",
"no_taxes": "Sin impuestos informados."
},
"actions": {
"back": "Volver",
"open_linked_proforma": "Abrir proforma vinculada",
"operation_in_progress": "La operación está en curso."
},
"payment_term": {
"due_days": "{{days}} días"
},
"items": {
"position": "Línea {{position}}"
},
"verifactu": {
"empty": "Esta factura no tiene un bloque VeriFactu disponible en el detalle actual.",
"qr_alt": "Código QR de VeriFactu",
"available": "Disponible",
"uses_verifactu": "Usa VeriFactu",
"does_not_use_verifactu": "No usa VeriFactu",
"policy_status": {
"not_configured": "No configurado",
"exempt": "Exenta o no aplicable",
"obligated_without_verifactu": "Obligada sin VeriFactu",
"obligated_with_verifactu": "Obligada con VeriFactu"
}
},
"fiscal": {
"unknown_tax_code": "Impuesto"
}
@ -1135,4 +1195,4 @@
}
}
}
}
}

View File

@ -50,6 +50,7 @@ export interface IssuedInvoiceDetailSummaryViewModel {
totalLabel: string;
documentReference: string;
seriesLabel: string;
invoiceNumberLabel: string;
invoiceDateLabel: string;
operationDateLabel: string | null;
statusLabel: string;
@ -64,12 +65,22 @@ export interface IssuedInvoiceDetailCustomerViewModel {
}
export interface IssuedInvoiceDetailCommercialViewModel {
paymentMethodLabel: string | null;
paymentTermLabel: string | null;
paymentMethodName: string | null;
paymentMethodDescription: string | null;
paymentTermName: string | null;
paymentTermDescription: string | null;
paymentTermDueRules: IssuedInvoiceDetailPaymentTermDueRuleViewModel[];
languageLabel: string;
currencyCode: string;
internalReferenceLabel: string | null;
}
export interface IssuedInvoiceDetailPaymentTermDueRuleViewModel {
key: string;
dueDaysLabel: string;
percentageLabel: string;
}
export interface IssuedInvoiceDetailFiscalBreakdownItemViewModel {
key: string;
taxLabel: string;
@ -83,10 +94,15 @@ export interface IssuedInvoiceDetailFiscalBreakdownItemViewModel {
export interface IssuedInvoiceDetailFiscalViewModel {
taxRegimeLabel: string;
fiscalClassification: IssuedInvoiceDetailFiscalFieldViewModel[];
verifactuPolicy: IssuedInvoiceDetailFiscalFieldViewModel[];
subtotalLabel: string;
itemsDiscountLabel: string | null;
globalDiscountLabel: string | null;
discountLabel: string;
taxableAmountLabel: string;
taxAmountLabel: string;
taxesAmountLabel: string;
recAmountLabel: string | null;
retentionAmountLabel: string | null;
totalAmountLabel: string;
@ -95,15 +111,24 @@ export interface IssuedInvoiceDetailFiscalViewModel {
usesRetention: boolean;
}
export interface IssuedInvoiceDetailFiscalFieldViewModel {
key: string;
label: string;
value: string;
}
export interface IssuedInvoiceDetailLinkedProformaViewModel {
linkedProformaId: string | null;
canOpenLinkedProforma: boolean;
}
export interface IssuedInvoiceDetailVerifactuViewModel {
status: VerifactuRecordStatus;
status: VerifactuRecordStatus | null;
hasRecord: boolean;
url: string | null;
qrCode: string | null;
qrValue: string | null;
qrMode: "image" | "text" | "generated" | null;
}
export interface IssuedInvoiceDetailContentViewModel {
@ -111,11 +136,54 @@ export interface IssuedInvoiceDetailContentViewModel {
notes: string | null;
}
export interface IssuedInvoiceDetailItemViewModel {
key: string;
positionLabel: string;
description: string;
quantityLabel: string | null;
unitAmountLabel: string | null;
subtotalLabel: string;
discountLabel: string | null;
taxableAmountLabel: string;
taxesLabel: string;
totalAmountLabel: string;
}
export interface IssuedInvoiceDetailTaxBreakdownViewModel {
key: string;
taxLabel: string;
taxableAmountLabel: string;
taxAmountLabel: string;
taxesAmountLabel: string;
recLabel: string | null;
recAmountLabel: string | null;
retentionLabel: string | null;
retentionAmountLabel: string | null;
}
export interface IssuedInvoiceDetailTotalsViewModel {
subtotalLabel: string;
itemsDiscountLabel: string | null;
globalDiscountLabel: string | null;
discountLabel: string;
taxableAmountLabel: string;
taxAmountLabel: string;
taxesAmountLabel: string;
recAmountLabel: string | null;
retentionAmountLabel: string | null;
totalAmountLabel: string;
usesRec: boolean;
usesRetention: boolean;
}
export interface IssuedInvoiceDetailGeneralViewModel {
summary: IssuedInvoiceDetailSummaryViewModel;
customer: IssuedInvoiceDetailCustomerViewModel;
commercial: IssuedInvoiceDetailCommercialViewModel;
fiscal: IssuedInvoiceDetailFiscalViewModel;
items: IssuedInvoiceDetailItemViewModel[];
taxes: IssuedInvoiceDetailTaxBreakdownViewModel[];
totals: IssuedInvoiceDetailTotalsViewModel;
linkedProforma: IssuedInvoiceDetailLinkedProformaViewModel;
verifactu: IssuedInvoiceDetailVerifactuViewModel;
content: IssuedInvoiceDetailContentViewModel;

View File

@ -2,8 +2,9 @@ import { Button } from "@repo/shadcn-ui/components";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import { ProformaDetailField } from "../../../../proformas/detail/ui/components";
import { VerifactuStatusBadge } from "../../../list/ui/components";
import type { IssuedInvoiceDetailGeneralViewModel } from "../../entities";
import { IssuedInvoiceDetailField } from "../components";
export interface IssuedInvoiceGeneralPanelProps {
general: IssuedInvoiceDetailGeneralViewModel;
@ -12,14 +13,69 @@ export interface IssuedInvoiceGeneralPanelProps {
const EMPTY_VALUE = "\u2014";
const buildPaymentTermLabel = (general: IssuedInvoiceDetailGeneralViewModel) => {
const dueRulesSummary = general.commercial.paymentTermDueRules
.map((rule) => `${rule.percentageLabel} · ${rule.dueDaysLabel}`)
.join(" / ");
return [
general.commercial.paymentTermName,
general.commercial.paymentTermDescription,
dueRulesSummary || null,
]
.filter(Boolean)
.join(" — ");
};
export const IssuedInvoiceGeneralPanel = ({
general,
onOpenLinkedProforma,
}: IssuedInvoiceGeneralPanelProps) => {
const { t } = useTranslation();
const paymentTermLabel = buildPaymentTermLabel(general);
return (
<div className="space-y-5 px-4 py-4 sm:px-5">
<div className="space-y-2 border rounded-lg bg-muted/10 px-4 py-4">
<p className="text-sm text-muted-foreground">
{t("pages.issued_invoices.detail.sections.verifactu")}
</p>
<div className="space-y-3">
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_status")}
value={
general.verifactu.hasRecord && general.verifactu.status ? (
<VerifactuStatusBadge className="sm:ml-auto" status={general.verifactu.status} />
) : (
EMPTY_VALUE
)
}
/>
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_url")}
value={
general.verifactu.url ? (
<a
className="break-all text-primary underline underline-offset-2"
href={general.verifactu.url}
rel="noreferrer noopener"
target="_blank"
>
{general.verifactu.url}
</a>
) : (
EMPTY_VALUE
)
}
valueClassName="sm:text-left"
/>
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_qr")}
value={general.verifactu.qrValue ? t("pages.issued_invoices.detail.verifactu.available") : EMPTY_VALUE}
/>
</div>
</div>
<div className="space-y-2">
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{general.summary.totalLabel}
@ -32,42 +88,35 @@ export const IssuedInvoiceGeneralPanel = ({
<div className="border-t" />
<div className="space-y-3">
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.invoice_reference")}
value={general.summary.documentReference}
valueClassName="font-mono font-medium text-foreground sm:text-right"
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.status")}
value={general.summary.statusLabel}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.invoice_date")}
value={general.summary.invoiceDateLabel}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.operation_date")}
value={general.summary.operationDateLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.internal_reference")}
value={general.commercial.internalReferenceLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.payment_method")}
value={general.commercial.paymentMethodLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
value={general.commercial.paymentMethodName ?? EMPTY_VALUE}
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.fields.payment_term")}
value={general.commercial.paymentTermLabel ?? EMPTY_VALUE}
valueClassName="sm:text-right"
value={paymentTermLabel || EMPTY_VALUE}
/>
<IssuedInvoiceDetailField
<ProformaDetailField
label={t("pages.issued_invoices.detail.sections.linked_proforma")}
value={
general.linkedProforma.canOpenLinkedProforma ? (

View File

@ -1 +1,3 @@
export * from './issued-invoice-detail-field';
export * from "./issued-invoice-detail-field";
export * from "./issued-invoice-detail-section";
export * from "./issued-invoice-detail-skeleton";

View File

@ -0,0 +1,38 @@
import { Separator } from "@repo/shadcn-ui/components";
import type { ReactNode } from "react";
import { useId } from "react";
export interface IssuedInvoiceDetailSectionProps {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
}
export const IssuedInvoiceDetailSection = ({
title,
description,
action,
children,
}: IssuedInvoiceDetailSectionProps) => {
const headingId = useId();
return (
<section>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold tracking-tight text-foreground" id={headingId}>
{title}
</h3>
{description ? (
<p className="text-sm leading-5 text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
<div className="space-y-4">{children}</div>
<Separator />
</section>
);
};

View File

@ -0,0 +1,46 @@
import { Skeleton } from "@repo/shadcn-ui/components";
export const IssuedInvoiceDetailSkeleton = () => {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-muted/30">
<div className="shrink-0 border-b bg-background px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<Skeleton className="size-10 rounded-md" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-56" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-28" />
<Skeleton className="h-10 w-28" />
<Skeleton className="size-10 rounded-md" />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Skeleton className="h-7 w-24 rounded-full" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-40" />
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="border-b bg-muted/30 p-4 sm:p-6 lg:min-h-0 lg:border-r lg:border-b-0">
<Skeleton className="h-full min-h-[32rem] w-full rounded-xl" />
</div>
<div className="bg-background p-4 sm:p-6">
<div className="space-y-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-[42rem] w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,374 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
const buildController = () => ({
state: {
isInitialLoading: false,
isRefetching: false,
pageError: null,
},
documentState: {
isInitialLoading: false,
isFetching: false,
isError: false,
errorMessage: null,
},
header: {
title: "Factura A-12",
documentReference: "A-12",
customerName: "Acme Labs",
status: "issued" as const,
statusLabel: "Emitida",
invoiceDateLabel: "10/08/2026",
},
document: {
title: "Documento",
description: "PDF",
src: "blob:invoice",
mode: "pdf" as const,
capabilities: {
canOpen: true,
canDownload: true,
canPrint: true,
},
},
general: {
summary: {
totalLabel: "121,00 €",
documentReference: "A-12",
seriesLabel: "A",
invoiceNumberLabel: "12",
invoiceDateLabel: "10/08/2026",
operationDateLabel: "09/08/2026",
statusLabel: "Emitida",
lineCount: 1,
totalUnitsLabel: "2",
},
customer: {
name: "Acme Labs",
tin: "B12345678",
addressLines: ["Calle Mayor 1", "28001 Madrid", "ES"],
},
commercial: {
paymentMethodName: "Transferencia",
paymentMethodDescription: "Transferencia bancaria",
paymentTermName: "30 días",
paymentTermDescription: "Pago estándar",
paymentTermDueRules: [],
languageLabel: "español",
currencyCode: "EUR",
internalReferenceLabel: "REF-CLI-12",
},
fiscal: {
taxRegimeLabel: "Régimen general",
fiscalClassification: [],
verifactuPolicy: [],
subtotalLabel: "100,00 €",
itemsDiscountLabel: null,
globalDiscountLabel: null,
discountLabel: "0,00 €",
taxableAmountLabel: "100,00 €",
taxAmountLabel: "21,00 €",
taxesAmountLabel: "21,00 €",
recAmountLabel: null,
retentionAmountLabel: null,
totalAmountLabel: "121,00 €",
breakdown: [],
usesRec: false,
usesRetention: false,
},
items: [],
taxes: [],
totals: {
subtotalLabel: "100,00 €",
itemsDiscountLabel: null,
globalDiscountLabel: null,
discountLabel: "0,00 €",
taxableAmountLabel: "100,00 €",
taxAmountLabel: "21,00 €",
taxesAmountLabel: "21,00 €",
recAmountLabel: null,
retentionAmountLabel: null,
totalAmountLabel: "121,00 €",
usesRec: false,
usesRetention: false,
},
linkedProforma: {
linkedProformaId: "proforma-1",
canOpenLinkedProforma: true,
},
verifactu: {
hasRecord: false,
status: null,
url: null,
qrCode: null,
qrValue: null,
qrMode: null as const,
},
content: {
description: "Factura de prueba",
notes: null,
},
},
actions: {
items: [],
},
navigation: {
navigateBack: () => undefined,
openLinkedProforma: () => undefined,
},
handlers: {
executeAction: () => undefined,
},
viewer: {
handleDownload: async () => undefined,
handleOpenPreviewPdf: async () => undefined,
handlePrintPreview: () => undefined,
handleRetryDocument: async () => undefined,
},
});
test("muestra el skeleton mientras la pantalla está cargando", async () => {
let skeletonRendered = false;
mock.module("../../../../i18n", {
namedExports: {
useTranslation: () => ({
t: (key: string) => key,
}),
},
});
mock.module("../../controllers", {
namedExports: {
useIssuedInvoiceDetailPageController: () => ({
...buildController(),
state: {
isInitialLoading: true,
isRefetching: false,
pageError: null,
},
}),
},
});
mock.module("../blocks", {
namedExports: {
IssuedInvoiceDetailContent: () => null,
IssuedInvoiceDetailHeader: () => null,
IssuedInvoiceDetailInspector: () => null,
IssuedInvoiceDocumentPane: () => null,
},
});
mock.module("../components", {
namedExports: {
IssuedInvoiceDetailSkeleton: () => {
skeletonRendered = true;
return null;
},
},
});
mock.module("@erp/core/components", {
namedExports: {
PageErrorState: () => null,
},
});
const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
renderToStaticMarkup(<IssuedInvoiceDetailPage />);
assert.equal(skeletonRendered, true);
mock.restoreAll();
});
test("usa PageErrorState para el error inicial bloqueante", async () => {
let capturedErrorProps:
| {
title?: string;
variant?: string;
reference?: string;
}
| undefined;
mock.module("../../../../i18n", {
namedExports: {
useTranslation: () => ({
t: (key: string) => key,
}),
},
});
mock.module("../../controllers", {
namedExports: {
useIssuedInvoiceDetailPageController: () => ({
...buildController(),
state: {
isInitialLoading: false,
isRefetching: false,
pageError: {
variant: "server",
title: "No se pudo cargar la factura",
description: "Error",
primaryAction: undefined,
secondaryAction: undefined,
reference: "corr-1",
},
},
}),
},
});
mock.module("@erp/core/components", {
namedExports: {
PageErrorState: (props: {
title?: string;
variant?: string;
reference?: string;
}) => {
capturedErrorProps = props;
return null;
},
},
});
mock.module("../blocks", {
namedExports: {
IssuedInvoiceDetailContent: () => null,
IssuedInvoiceDetailHeader: () => null,
IssuedInvoiceDetailInspector: () => null,
IssuedInvoiceDocumentPane: () => null,
},
});
mock.module("../components", {
namedExports: {
IssuedInvoiceDetailSkeleton: () => null,
},
});
const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
renderToStaticMarkup(<IssuedInvoiceDetailPage />);
assert.equal(capturedErrorProps?.title, "No se pudo cargar la factura");
assert.equal(capturedErrorProps?.variant, "server");
assert.equal(capturedErrorProps?.reference, "corr-1");
mock.restoreAll();
});
test("compone header, visor e inspector con el detalle emitido y la navegación a proforma", async () => {
let capturedInspectorProps:
| {
general?: {
linkedProforma?: {
linkedProformaId?: string | null;
};
verifactu?: {
hasRecord?: boolean;
};
};
onOpenLinkedProforma?: () => void;
}
| undefined;
let capturedHeaderProps:
| {
header?: {
documentReference?: string;
};
}
| undefined;
let capturedDocumentPaneProps:
| {
document?: {
src?: string | null;
};
}
| undefined;
mock.module("../../../../i18n", {
namedExports: {
useTranslation: () => ({
t: (key: string) => key,
}),
},
});
mock.module("../../controllers", {
namedExports: {
useIssuedInvoiceDetailPageController: () => buildController(),
},
});
mock.module("@erp/core/components", {
namedExports: {
PageErrorState: () => null,
},
});
mock.module("../components", {
namedExports: {
IssuedInvoiceDetailSkeleton: () => null,
},
});
mock.module("../blocks", {
namedExports: {
IssuedInvoiceDetailHeader: (props: {
header?: {
documentReference?: string;
};
}) => {
capturedHeaderProps = props;
return null;
},
IssuedInvoiceDetailContent: ({
documentPane,
inspector,
}: {
documentPane: ReactNode;
inspector: ReactNode;
}) => (
<>
{documentPane}
{inspector}
</>
),
IssuedInvoiceDocumentPane: (props: {
document?: {
src?: string | null;
};
}) => {
capturedDocumentPaneProps = props;
return null;
},
IssuedInvoiceDetailInspector: (props: {
general?: {
linkedProforma?: {
linkedProformaId?: string | null;
};
verifactu?: {
hasRecord?: boolean;
};
};
onOpenLinkedProforma?: () => void;
}) => {
capturedInspectorProps = props;
return null;
},
},
});
const { IssuedInvoiceDetailPage } = await import("./issued-invoice-detail-page.tsx");
renderToStaticMarkup(<IssuedInvoiceDetailPage />);
assert.equal(capturedHeaderProps?.header?.documentReference, "A-12");
assert.equal(capturedDocumentPaneProps?.document?.src, "blob:invoice");
assert.equal(capturedInspectorProps?.general?.linkedProforma?.linkedProformaId, "proforma-1");
assert.equal(typeof capturedInspectorProps?.onOpenLinkedProforma, "function");
assert.equal(capturedInspectorProps?.general?.verifactu?.hasRecord, false);
mock.restoreAll();
});

View File

@ -1,5 +1,4 @@
import { PageErrorState } from "@erp/core/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import { useIssuedInvoiceDetailPageController } from "../../controllers";
@ -9,20 +8,14 @@ import {
IssuedInvoiceDetailInspector,
IssuedInvoiceDocumentPane,
} from "../blocks";
import { IssuedInvoiceDetailSkeleton } from "../components";
export const IssuedInvoiceDetailPage = () => {
const { t } = useTranslation();
const ctrl = useIssuedInvoiceDetailPageController();
if (ctrl.state.isInitialLoading) {
return (
<div className="flex h-full min-h-[24rem] items-center justify-center p-6">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-4" />
<span>{t("pages.issued_invoices.detail.document.loading")}</span>
</div>
</div>
);
return <IssuedInvoiceDetailSkeleton />;
}
if (ctrl.state.pageError) {

View File

@ -0,0 +1,9 @@
export * from "./issued-invoice-document-section";
export * from "./issued-invoice-fiscal-section";
export * from "./issued-invoice-items-section";
export * from "./issued-invoice-payment-section";
export * from "./issued-invoice-recipient-section";
export * from "./issued-invoice-summary-section";
export * from "./issued-invoice-taxes-section";
export * from "./issued-invoice-totals-section";
export * from "./issued-invoice-verifactu-section";

View File

@ -0,0 +1,71 @@
import { Button } from "@repo/shadcn-ui/components";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
import type {
IssuedInvoiceDetailCommercialViewModel,
IssuedInvoiceDetailLinkedProformaViewModel,
IssuedInvoiceDetailSummaryViewModel,
} from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceDocumentSectionProps = {
summary: IssuedInvoiceDetailSummaryViewModel;
commercial: IssuedInvoiceDetailCommercialViewModel;
linkedProforma: IssuedInvoiceDetailLinkedProformaViewModel;
onOpenLinkedProforma: () => void;
};
export const IssuedInvoiceDocumentSection = ({
summary,
commercial,
linkedProforma,
onOpenLinkedProforma,
}: IssuedInvoiceDocumentSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.document")}
title={t("pages.issued_invoices.detail.sections.document")}
>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.series")}
value={summary.seriesLabel}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.language")}
value={commercial.languageLabel}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.currency")}
value={commercial.currencyCode}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.internal_reference")}
value={commercial.internalReferenceLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.linked_proforma")}
value={
linkedProforma.canOpenLinkedProforma ? (
<Button
className="h-auto px-0 py-0 font-medium"
onClick={onOpenLinkedProforma}
type="button"
variant="link"
>
<ExternalLinkIcon aria-hidden="true" className="mr-1 size-4" />
{t("pages.issued_invoices.detail.actions.open_linked_proforma")}
</Button>
) : (
t("pages.issued_invoices.detail.empty.not_available")
)
}
valueClassName="sm:text-right"
/>
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,33 @@
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailFiscalViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceFiscalSectionProps = {
fiscal: IssuedInvoiceDetailFiscalViewModel;
};
export const IssuedInvoiceFiscalSection = ({
fiscal,
}: IssuedInvoiceFiscalSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.fiscal")}
title={t("pages.issued_invoices.detail.sections.fiscal")}
>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.tax_regime")}
value={fiscal.taxRegimeLabel}
/>
{fiscal.fiscalClassification.map((field) => (
<IssuedInvoiceDetailField key={field.key} label={field.label} value={field.value} />
))}
{fiscal.verifactuPolicy.map((field) => (
<IssuedInvoiceDetailField key={field.key} label={field.label} value={field.value} />
))}
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,73 @@
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailItemViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceItemsSectionProps = {
items: IssuedInvoiceDetailItemViewModel[];
};
export const IssuedInvoiceItemsSection = ({ items }: IssuedInvoiceItemsSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.items")}
title={t("pages.issued_invoices.detail.sections.items")}
>
<div className="space-y-4">
{items.map((item) => (
<div className="space-y-3 rounded-lg border bg-muted/10 p-4" key={item.key}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-foreground">
{t("pages.issued_invoices.detail.items.position", {
position: item.positionLabel,
})}
</p>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
<div className="space-y-2">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.quantity")}
value={item.quantityLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.unit_amount")}
value={item.unitAmountLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.subtotal_amount")}
value={item.subtotalLabel}
valueClassName="tabular-nums"
/>
{item.discountLabel ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.item_discount")}
value={item.discountLabel}
valueClassName="tabular-nums"
/>
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.taxable_amount")}
value={item.taxableAmountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.item_taxes")}
value={item.taxesLabel}
valueClassName="sm:text-left"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.total_amount")}
value={item.totalAmountLabel}
valueClassName="tabular-nums text-base"
/>
</div>
</div>
))}
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,63 @@
import { Badge } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailCommercialViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoicePaymentSectionProps = {
commercial: IssuedInvoiceDetailCommercialViewModel;
};
export const IssuedInvoicePaymentSection = ({
commercial,
}: IssuedInvoicePaymentSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.commercial")}
title={t("pages.issued_invoices.detail.sections.commercial")}
>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_method")}
value={commercial.paymentMethodName ?? t("pages.issued_invoices.detail.empty.not_available")}
/>
{commercial.paymentMethodDescription ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_method_description")}
value={commercial.paymentMethodDescription}
valueClassName="sm:text-left"
/>
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_term")}
value={commercial.paymentTermName ?? t("pages.issued_invoices.detail.empty.not_available")}
/>
{commercial.paymentTermDescription ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_term_description")}
value={commercial.paymentTermDescription}
valueClassName="sm:text-left"
/>
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.payment_term_due_rules")}
value={
commercial.paymentTermDueRules.length > 0 ? (
<div className="flex flex-wrap gap-2 sm:justify-end">
{commercial.paymentTermDueRules.map((rule) => (
<Badge className="text-xs" key={rule.key} variant="secondary">
{rule.percentageLabel} · {rule.dueDaysLabel}
</Badge>
))}
</div>
) : (
t("pages.issued_invoices.detail.empty.not_available")
)
}
/>
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,46 @@
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailCustomerViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceRecipientSectionProps = {
customer: IssuedInvoiceDetailCustomerViewModel;
};
export const IssuedInvoiceRecipientSection = ({
customer,
}: IssuedInvoiceRecipientSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.customer")}
title={t("pages.issued_invoices.detail.sections.customer")}
>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.customer_name")}
value={customer.name}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.customer_tin")}
value={customer.tin ?? t("pages.issued_invoices.detail.empty.not_available")}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.address")}
value={
customer.addressLines.length > 0 ? (
<div className="space-y-1 whitespace-pre-wrap">
{customer.addressLines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : (
t("pages.issued_invoices.detail.empty.no_address")
)
}
valueClassName="sm:text-left"
/>
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,82 @@
import { useTranslation } from "../../../../i18n";
import type {
IssuedInvoiceDetailContentViewModel,
IssuedInvoiceDetailSummaryViewModel,
} from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceSummarySectionProps = {
summary: IssuedInvoiceDetailSummaryViewModel;
content: IssuedInvoiceDetailContentViewModel;
};
export const IssuedInvoiceSummarySection = ({
summary,
content,
}: IssuedInvoiceSummarySectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.summary")}
title={t("pages.issued_invoices.detail.sections.summary")}
>
<div className="rounded-lg border bg-muted/20 px-4 py-4">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("pages.issued_invoices.detail.fields.total_amount")}
</p>
<p className="mt-2 text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{summary.totalLabel}
</p>
</div>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.invoice_reference")}
value={summary.documentReference}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.invoice_number")}
value={summary.invoiceNumberLabel}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.status")}
value={summary.statusLabel}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.invoice_date")}
value={summary.invoiceDateLabel}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.operation_date")}
value={summary.operationDateLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.line_count")}
value={String(summary.lineCount)}
/>
{summary.totalUnitsLabel ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.total_units")}
value={summary.totalUnitsLabel}
valueClassName="tabular-nums"
/>
) : null}
{content.description ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.description")}
value={content.description}
valueClassName="whitespace-pre-wrap sm:text-left"
/>
) : null}
{content.notes ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.notes")}
value={content.notes}
valueClassName="whitespace-pre-wrap sm:text-left"
/>
) : null}
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,57 @@
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailTaxBreakdownViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceTaxesSectionProps = {
taxes: IssuedInvoiceDetailTaxBreakdownViewModel[];
};
export const IssuedInvoiceTaxesSection = ({ taxes }: IssuedInvoiceTaxesSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.taxes")}
title={t("pages.issued_invoices.detail.sections.taxes")}
>
<div className="space-y-4">
{taxes.map((item) => (
<div className="space-y-3 rounded-lg border bg-muted/10 p-4" key={item.key}>
<p className="text-sm font-semibold text-foreground">{item.taxLabel}</p>
<div className="space-y-2">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.taxable_amount")}
value={item.taxableAmountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.tax_amount")}
value={item.taxAmountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.taxes_amount")}
value={item.taxesAmountLabel}
valueClassName="tabular-nums"
/>
{item.recLabel && item.recAmountLabel ? (
<IssuedInvoiceDetailField
label={item.recLabel}
value={item.recAmountLabel}
valueClassName="tabular-nums"
/>
) : null}
{item.retentionLabel && item.retentionAmountLabel ? (
<IssuedInvoiceDetailField
label={item.retentionLabel}
value={item.retentionAmountLabel}
valueClassName="tabular-nums"
/>
) : null}
</div>
</div>
))}
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,79 @@
import { useTranslation } from "../../../../i18n";
import type { IssuedInvoiceDetailTotalsViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceTotalsSectionProps = {
totals: IssuedInvoiceDetailTotalsViewModel;
};
export const IssuedInvoiceTotalsSection = ({ totals }: IssuedInvoiceTotalsSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.totals")}
title={t("pages.issued_invoices.detail.sections.totals")}
>
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.subtotal_amount")}
value={totals.subtotalLabel}
valueClassName="tabular-nums"
/>
{totals.itemsDiscountLabel ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.items_discount_amount")}
value={totals.itemsDiscountLabel}
valueClassName="tabular-nums"
/>
) : null}
{totals.globalDiscountLabel ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.global_discount_amount")}
value={totals.globalDiscountLabel}
valueClassName="tabular-nums"
/>
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.discount_amount")}
value={totals.discountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.taxable_amount")}
value={totals.taxableAmountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.tax_amount")}
value={totals.taxAmountLabel}
valueClassName="tabular-nums"
/>
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.taxes_amount")}
value={totals.taxesAmountLabel}
valueClassName="tabular-nums"
/>
{totals.usesRec ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.rec_amount")}
value={totals.recAmountLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
valueClassName="tabular-nums"
/>
) : null}
{totals.usesRetention ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.retention_amount")}
value={totals.retentionAmountLabel ?? t("pages.issued_invoices.detail.empty.not_available")}
valueClassName="tabular-nums"
/>
) : null}
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.total_amount")}
value={totals.totalAmountLabel}
valueClassName="tabular-nums text-base font-semibold"
/>
</div>
</IssuedInvoiceDetailSection>
);
};

View File

@ -0,0 +1,99 @@
import { ReactQRCode } from "@lglab/react-qr-code";
import { useTranslation } from "../../../../i18n";
import { VerifactuStatusBadge } from "../../../list/ui/components";
import type { IssuedInvoiceDetailVerifactuViewModel } from "../../entities";
import { IssuedInvoiceDetailField, IssuedInvoiceDetailSection } from "../components";
type IssuedInvoiceVerifactuSectionProps = {
verifactu: IssuedInvoiceDetailVerifactuViewModel;
};
export const IssuedInvoiceVerifactuSection = ({
verifactu,
}: IssuedInvoiceVerifactuSectionProps) => {
const { t } = useTranslation();
return (
<IssuedInvoiceDetailSection
description={t("pages.issued_invoices.detail.section_descriptions.verifactu")}
title={t("pages.issued_invoices.detail.sections.verifactu")}
>
{!verifactu.hasRecord ? (
<p className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-sm text-muted-foreground">
{t("pages.issued_invoices.detail.verifactu.empty")}
</p>
) : (
<div className="space-y-3">
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_status")}
value={
verifactu.status ? (
<VerifactuStatusBadge className="sm:ml-auto" status={verifactu.status} />
) : (
t("pages.issued_invoices.detail.empty.not_available")
)
}
/>
{verifactu.url ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_url")}
value={
<a
className="break-all text-primary underline underline-offset-2"
href={verifactu.url}
rel="noreferrer noopener"
target="_blank"
>
{verifactu.url}
</a>
}
valueClassName="sm:text-left"
/>
) : null}
{verifactu.qrMode === "image" && verifactu.qrValue ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_qr")}
value={
<img
alt={t("pages.issued_invoices.detail.verifactu.qr_alt")}
className="max-w-40 rounded border bg-white p-2 sm:ml-auto"
src={verifactu.qrValue}
/>
}
/>
) : null}
{verifactu.qrMode === "generated" && verifactu.qrValue ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_qr")}
value={
<div className="inline-flex rounded border bg-white p-2 sm:ml-auto">
<ReactQRCode
background="#fff"
dataModulesSettings={{ color: "#000" }}
finderPatternInnerSettings={{ color: "#000" }}
finderPatternOuterSettings={{ color: "#000" }}
marginSize={6}
size={132}
value={verifactu.qrValue}
/>
</div>
}
/>
) : null}
{verifactu.qrMode === "text" && verifactu.qrValue ? (
<IssuedInvoiceDetailField
label={t("pages.issued_invoices.detail.fields.verifactu_qr")}
value={
<code className="block whitespace-pre-wrap break-all rounded border bg-muted/20 px-3 py-2 text-xs sm:text-left">
{verifactu.qrValue}
</code>
}
valueClassName="sm:text-left"
/>
) : null}
</div>
)}
</IssuedInvoiceDetailSection>
);
};

View File

@ -111,6 +111,19 @@ const buildInvoice = (): IssuedInvoice => ({
},
taxRegimeCode: "general",
taxRegimeDescription: null,
fiscalClassification: {
taxRegimeCode: "general",
taxRegimeLabel: "Régimen general",
operationQualificationCode: "S1",
operationQualificationLabel: "Sujeta",
exemptionReasonCode: null,
exemptionReasonLabel: null,
},
verifactuPolicy: {
profileStatus: "obligated_with_verifactu",
usesVerifactu: true,
effectiveAt: "2026-01-01T00:00:00.000Z",
},
subtotalAmount: 150,
itemsDiscountAmount: 0,
globalDiscountPercentage: 0,
@ -187,22 +200,42 @@ test("construye el view model general con resumen fiscal, unidades y enlace a pr
});
assert.equal(viewModel.summary.documentReference, "A-12");
assert.equal(viewModel.summary.invoiceNumberLabel, "12");
assert.equal(viewModel.summary.statusLabel, "Emitida");
assert.equal(viewModel.summary.totalUnitsLabel, "3.5");
assert.equal(viewModel.customer.addressLines.length, 3);
assert.equal(viewModel.commercial.paymentMethodLabel, null);
assert.equal(
viewModel.commercial.paymentTermLabel,
"30 días — Pago estándar — 100.00% a 30 días"
);
assert.equal(viewModel.commercial.paymentMethodName, null);
assert.equal(viewModel.commercial.paymentTermName, "30 días");
assert.equal(viewModel.commercial.paymentTermDueRules[0]?.dueDaysLabel, "30 días");
assert.equal(viewModel.fiscal.taxRegimeLabel, "general");
assert.equal(viewModel.fiscal.fiscalClassification[0]?.value, "Régimen general");
assert.equal(viewModel.fiscal.verifactuPolicy[0]?.value, "obligated_with_verifactu");
assert.equal(viewModel.fiscal.usesRec, true);
assert.equal(viewModel.fiscal.usesRetention, true);
assert.equal(viewModel.fiscal.breakdown[1]?.taxLabel, "Impuesto");
assert.equal(viewModel.fiscal.breakdown[1]?.recLabel, "RE · 5.2%");
assert.equal(viewModel.fiscal.breakdown[1]?.retentionLabel, "IRPF · 15%");
assert.equal(viewModel.items[0]?.positionLabel, "1");
assert.equal(viewModel.totals.taxesAmountLabel, "16,10 €");
assert.equal(viewModel.linkedProforma.canOpenLinkedProforma, true);
assert.equal(viewModel.verifactu.url, "https://example.com/verifactu/A-12");
assert.equal(viewModel.verifactu.qrMode, "generated");
});
test("mantiene verifactu a null sin fabricar estado ni qr", () => {
const viewModel = buildIssuedInvoiceDetailGeneralViewModel(
{
...buildInvoice(),
verifactu: null,
},
{
translate,
}
);
assert.equal(viewModel.verifactu.hasRecord, false);
assert.equal(viewModel.verifactu.status, null);
assert.equal(viewModel.verifactu.qrMode, null);
});
test("mapea errores desconocidos del detalle al variant unknown sin exponer detalles técnicos", () => {

View File

@ -8,12 +8,16 @@ import type {
IssuedInvoiceDetailContentViewModel,
IssuedInvoiceDetailCustomerViewModel,
IssuedInvoiceDetailDocumentViewModel,
IssuedInvoiceDetailFiscalFieldViewModel,
IssuedInvoiceDetailFiscalBreakdownItemViewModel,
IssuedInvoiceDetailFiscalViewModel,
IssuedInvoiceDetailGeneralViewModel,
IssuedInvoiceDetailHeaderViewModel,
IssuedInvoiceDetailItemViewModel,
IssuedInvoiceDetailPageErrorViewModel,
IssuedInvoiceDetailSummaryViewModel,
IssuedInvoiceDetailTaxBreakdownViewModel,
IssuedInvoiceDetailTotalsViewModel,
IssuedInvoiceDetailVerifactuViewModel,
} from "../entities";
@ -97,32 +101,55 @@ const buildTaxLabel = (
return percentage ? `${code} · ${percentage}` : code;
};
const buildPaymentTermLabel = (issuedInvoice: IssuedInvoice): string | null => {
if (issuedInvoice.paymentTerm == null) {
const buildPercentageLabel = (value: number | null, scale = 2) => {
if (value === null) {
return null;
}
const dueRulesSummary = issuedInvoice.paymentTerm.dueRules
.map((rule) => {
const percentageLabel = NumberHelper.formatNumber(
PercentageDTOHelper.toNumber({
value: rule.percentage.value,
scale: String(rule.percentage.scale),
}),
rule.percentage.scale
);
return `${NumberHelper.formatNumber(value, scale)}%`;
};
return `${percentageLabel}% a ${rule.dueDays} días`;
})
.join(" / ");
const buildPolicyStatusLabel = (
profileStatus: NonNullable<IssuedInvoice["verifactuPolicy"]>["profileStatus"],
translate: IssuedInvoiceDetailTranslate
) => {
return translate(`pages.issued_invoices.detail.verifactu.policy_status.${profileStatus}`, {
defaultValue: profileStatus,
});
};
const segments = [
issuedInvoice.paymentTerm.name,
issuedInvoice.paymentTerm.description,
dueRulesSummary.length > 0 ? dueRulesSummary : null,
const buildItemTaxesLabel = (
item: IssuedInvoice["items"][number],
translate: IssuedInvoiceDetailTranslate
) => {
const taxPercentageLabel = buildPercentageLabel(item.taxPercentage);
const recPercentageLabel = buildPercentageLabel(item.recPercentage);
const retentionPercentageLabel = buildPercentageLabel(item.retentionPercentage);
const parts = [
item.taxCode ? (taxPercentageLabel ? `${item.taxCode} · ${taxPercentageLabel}` : item.taxCode) : null,
item.recCode ? (recPercentageLabel ? `${item.recCode} · ${recPercentageLabel}` : item.recCode) : null,
item.retentionCode
? retentionPercentageLabel
? `${item.retentionCode} · ${retentionPercentageLabel}`
: item.retentionCode
: null,
].filter(Boolean);
return segments.length > 0 ? segments.join(" — ") : null;
return parts.length > 0
? parts.join(" / ")
: translate("pages.issued_invoices.detail.empty.no_taxes");
};
const buildFiscalField = (
key: string,
label: string,
value: string | null
): IssuedInvoiceDetailFiscalFieldViewModel | null => {
if (!value) {
return null;
}
return { key, label, value };
};
const buildPageErrorCopy = (
@ -241,6 +268,14 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
): IssuedInvoiceDetailGeneralViewModel => {
const usesRec = issuedInvoice.recAmount > 0;
const usesRetention = issuedInvoice.retentionAmount > 0;
const itemsDiscountLabel =
issuedInvoice.itemsDiscountAmount > 0
? formatMoney(issuedInvoice, issuedInvoice.itemsDiscountAmount)
: null;
const globalDiscountLabel =
issuedInvoice.globalDiscountAmount > 0
? `${formatMoney(issuedInvoice, issuedInvoice.globalDiscountAmount)} (${NumberHelper.formatNumber(issuedInvoice.globalDiscountPercentage, 2)}%)`
: null;
const breakdown: IssuedInvoiceDetailFiscalBreakdownItemViewModel[] = issuedInvoice.taxes.map(
(tax, index) => ({
@ -248,6 +283,7 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
taxLabel: buildTaxLabel(tax, context.translate),
taxableAmountLabel: formatMoney(issuedInvoice, tax.taxableAmount),
taxAmountLabel: formatMoney(issuedInvoice, tax.ivaAmount),
taxesAmountLabel: formatMoney(issuedInvoice, tax.taxesAmount),
recLabel:
tax.recPercentage === null
? null
@ -266,6 +302,7 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
totalLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount),
documentReference: issuedInvoice.invoiceReference,
seriesLabel: issuedInvoice.series,
invoiceNumberLabel: issuedInvoice.invoiceNumber,
invoiceDateLabel: DateHelper.format(issuedInvoice.invoiceDate),
operationDateLabel: formatDate(issuedInvoice.operationDate),
statusLabel: context.translate(
@ -285,18 +322,86 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
};
const commercial: IssuedInvoiceDetailCommercialViewModel = {
paymentMethodLabel: issuedInvoice.paymentMethodName,
paymentTermLabel: buildPaymentTermLabel(issuedInvoice),
paymentMethodName: issuedInvoice.paymentMethodName,
paymentMethodDescription: issuedInvoice.paymentMethodDescription,
paymentTermName: issuedInvoice.paymentTerm?.name ?? null,
paymentTermDescription: issuedInvoice.paymentTerm?.description ?? null,
paymentTermDueRules:
issuedInvoice.paymentTerm?.dueRules.map((rule, index) => ({
key: `${rule.dueDays}-${rule.percentage.value}-${index}`,
dueDaysLabel: context.translate("pages.issued_invoices.detail.payment_term.due_days", {
days: rule.dueDays,
}),
percentageLabel: `${NumberHelper.formatNumber(
PercentageDTOHelper.toNumber({
value: rule.percentage.value,
scale: String(rule.percentage.scale),
}),
rule.percentage.scale
)}%`,
})) ?? [],
languageLabel: buildLanguageLabel(issuedInvoice.languageCode),
currencyCode: issuedInvoice.currencyCode,
internalReferenceLabel: issuedInvoice.reference,
};
const fiscalClassification = [
buildFiscalField(
"tax-regime",
context.translate("pages.issued_invoices.detail.fields.fiscal_tax_regime"),
issuedInvoice.fiscalClassification?.taxRegimeLabel ??
issuedInvoice.fiscalClassification?.taxRegimeCode ??
null
),
buildFiscalField(
"operation-qualification",
context.translate("pages.issued_invoices.detail.fields.operation_qualification"),
issuedInvoice.fiscalClassification?.operationQualificationLabel ??
issuedInvoice.fiscalClassification?.operationQualificationCode ??
null
),
buildFiscalField(
"exemption-reason",
context.translate("pages.issued_invoices.detail.fields.exemption_reason"),
issuedInvoice.fiscalClassification?.exemptionReasonLabel ??
issuedInvoice.fiscalClassification?.exemptionReasonCode ??
null
),
].filter((item): item is IssuedInvoiceDetailFiscalFieldViewModel => item !== null);
const verifactuPolicy = issuedInvoice.verifactuPolicy
? [
buildFiscalField(
"profile-status",
context.translate("pages.issued_invoices.detail.fields.verifactu_profile_status"),
buildPolicyStatusLabel(issuedInvoice.verifactuPolicy.profileStatus, context.translate)
),
buildFiscalField(
"uses-verifactu",
context.translate("pages.issued_invoices.detail.fields.verifactu_policy"),
issuedInvoice.verifactuPolicy.usesVerifactu
? context.translate("pages.issued_invoices.detail.verifactu.uses_verifactu")
: context.translate("pages.issued_invoices.detail.verifactu.does_not_use_verifactu")
),
buildFiscalField(
"effective-at",
context.translate("pages.issued_invoices.detail.fields.verifactu_effective_at"),
formatDate(issuedInvoice.verifactuPolicy.effectiveAt)
),
].filter((item): item is IssuedInvoiceDetailFiscalFieldViewModel => item !== null)
: [];
const fiscal: IssuedInvoiceDetailFiscalViewModel = {
taxRegimeLabel: issuedInvoice.taxRegimeDescription ?? issuedInvoice.taxRegimeCode,
fiscalClassification,
verifactuPolicy,
subtotalLabel: formatMoney(issuedInvoice, issuedInvoice.subtotalAmount),
itemsDiscountLabel,
globalDiscountLabel,
discountLabel: formatMoney(issuedInvoice, issuedInvoice.totalDiscountAmount),
taxableAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxableAmount),
taxAmountLabel: formatMoney(issuedInvoice, issuedInvoice.ivaAmount),
taxesAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxesAmount),
recAmountLabel: usesRec ? formatMoney(issuedInvoice, issuedInvoice.recAmount) : null,
retentionAmountLabel: usesRetention
? formatMoney(issuedInvoice, issuedInvoice.retentionAmount)
@ -308,9 +413,63 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
};
const verifactu: IssuedInvoiceDetailVerifactuViewModel = {
status: issuedInvoice.verifactu.status,
url: issuedInvoice.verifactu.url,
qrCode: issuedInvoice.verifactu.qrCode,
hasRecord: issuedInvoice.verifactu !== null,
status: issuedInvoice.verifactu?.status ?? null,
url: issuedInvoice.verifactu?.url ?? null,
qrCode: issuedInvoice.verifactu?.qrCode ?? null,
qrValue: issuedInvoice.verifactu?.qrCode ?? issuedInvoice.verifactu?.url ?? null,
qrMode: issuedInvoice.verifactu?.qrCode
? issuedInvoice.verifactu.qrCode.startsWith("data:image/")
? "image"
: "text"
: issuedInvoice.verifactu?.url
? "generated"
: null,
};
const items: IssuedInvoiceDetailItemViewModel[] = issuedInvoice.items.map((item) => ({
key: item.id,
positionLabel: String(item.position),
description:
item.description || context.translate("pages.issued_invoices.detail.empty.no_description"),
quantityLabel:
item.quantity === null
? null
: NumberHelper.formatNumber(item.quantity, Number.isInteger(item.quantity) ? 0 : 2),
unitAmountLabel: item.unitAmount === null ? null : formatMoney(issuedInvoice, item.unitAmount),
subtotalLabel: formatMoney(issuedInvoice, item.subtotalAmount),
discountLabel:
item.itemDiscountAmount > 0
? `${formatMoney(issuedInvoice, item.itemDiscountAmount)}${
item.itemDiscountPercentage !== null
? ` (${NumberHelper.formatNumber(item.itemDiscountPercentage, 2)}%)`
: ""
}`
: null,
taxableAmountLabel: formatMoney(issuedInvoice, item.taxableAmount),
taxesLabel: buildItemTaxesLabel(item, context.translate),
totalAmountLabel: formatMoney(issuedInvoice, item.totalAmount),
}));
const taxes: IssuedInvoiceDetailTaxBreakdownViewModel[] = breakdown.map((item) => ({
...item,
}));
const totals: IssuedInvoiceDetailTotalsViewModel = {
subtotalLabel: formatMoney(issuedInvoice, issuedInvoice.subtotalAmount),
itemsDiscountLabel,
globalDiscountLabel,
discountLabel: formatMoney(issuedInvoice, issuedInvoice.totalDiscountAmount),
taxableAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxableAmount),
taxAmountLabel: formatMoney(issuedInvoice, issuedInvoice.ivaAmount),
taxesAmountLabel: formatMoney(issuedInvoice, issuedInvoice.taxesAmount),
recAmountLabel: usesRec ? formatMoney(issuedInvoice, issuedInvoice.recAmount) : null,
retentionAmountLabel: usesRetention
? formatMoney(issuedInvoice, issuedInvoice.retentionAmount)
: null,
totalAmountLabel: formatMoney(issuedInvoice, issuedInvoice.totalAmount),
usesRec,
usesRetention,
};
const content: IssuedInvoiceDetailContentViewModel = {
@ -323,6 +482,9 @@ export const buildIssuedInvoiceDetailGeneralViewModel = (
customer,
commercial,
fiscal,
items,
taxes,
totals,
linkedProforma: {
linkedProformaId: issuedInvoice.linkedProformaId,
canOpenLinkedProforma: Boolean(issuedInvoice.linkedProformaId),

View File

@ -33,27 +33,28 @@ export function useIssuedInvoicesGridColumns(
() => [
// Nº
{
id: "series_invoice_number",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right tabular-nums justify-end"
column={column}
title={t("pages.issued_invoices.list.grid_columns.series_invoice_number")}
title={t("pages.issued_invoices.list.grid_columns.invoice_number")}
/>
),
cell: ({ row }) => (
accessorKey: "invoiceReference",
/*cell: ({ row }) => (
<div className="text-right tabular-nums">
{row.original.series}
{row.original.invoiceNumber}
</div>
),
),*/
enableHiding: false,
enableSorting: false,
maxSize: 96,
enableSorting: true,
/*maxSize: 96,
size: 96,
minSize: 96,
minSize: 96,*/
meta: {
title: t("pages.issued_invoices.list.grid_columns.series_invoice_number"),
title: t("pages.issued_invoices.list.grid_columns.invoice_reference"),
cellClassName: "font-mono text-sm font-medium",
},
},
{

View File

@ -70,6 +70,19 @@ const buildDto = (): GetIssuedInvoiceByIdResponseDTO => ({
code: "general",
description: "Régimen general",
},
fiscal_classification: {
tax_regime_code: "general",
tax_regime_label: "Régimen general",
operation_qualification_code: "S1",
operation_qualification_label: "Sujeta",
exemption_reason_code: null,
exemption_reason_label: null,
},
verifactu_policy: {
profile_status: "obligated_with_verifactu",
uses_verifactu: true,
effective_at: "2026-01-01T00:00:00.000Z",
},
subtotal_amount: MoneyDTOHelper.fromNumber(100, "EUR"),
items_discount_amount: MoneyDTOHelper.fromNumber(5, "EUR"),
global_discount_percentage: PercentageDTOHelper.fromNumber(10),
@ -125,6 +138,8 @@ test("adapta el DTO de detalle a una entidad con nullables y valores numéricos
assert.equal(invoice.paymentMethodName, "Transferencia");
assert.equal(invoice.paymentTerm?.name, "30 días");
assert.equal(invoice.paymentTerm?.dueRules[0]?.dueDays, "30");
assert.equal(invoice.fiscalClassification?.operationQualificationCode, "S1");
assert.equal(invoice.verifactuPolicy?.usesVerifactu, true);
assert.equal(invoice.globalDiscountPercentage, 10);
assert.equal(invoice.taxableAmount, 85);
assert.equal(invoice.totalAmount, 91);
@ -133,3 +148,12 @@ test("adapta el DTO de detalle a una entidad con nullables y valores numéricos
assert.equal(invoice.items[0]?.unitAmount, 50);
assert.equal(invoice.items[0]?.retentionPercentage, 15);
});
test("acepta facturas sin bloque verifactu sin inventar estado desde el frontend", () => {
const invoice = GetIssuedInvoiceByIdAdapter.fromDto({
...buildDto(),
verifactu: null,
});
assert.equal(invoice.verifactu, null);
});

View File

@ -4,11 +4,13 @@ import type { GetIssuedInvoiceByIdResponseDTO } from "../../../../common";
import type { GetIssuedInvoiceByIdResult } from "../api";
import type {
IssuedInvoice,
IssuedInvoiceFiscalClassification,
IssuedInvoiceItem,
IssuedInvoicePaymentTermDetail,
IssuedInvoiceRecipientDetail,
IssuedInvoiceStatus,
IssuedInvoiceTaxSummary,
IssuedInvoiceVerifactuPolicy,
IssuedInvoiceVerifactuRecord,
VerifactuRecordStatus,
} from "../entities";
@ -41,6 +43,8 @@ export const GetIssuedInvoiceByIdAdapter = {
paymentTerm: mapPaymentTerm(dto.payment_term),
taxRegimeCode: dto.tax_regime.code,
taxRegimeDescription: dto.tax_regime.description,
fiscalClassification: mapFiscalClassification(dto.fiscal_classification),
verifactuPolicy: mapVerifactuPolicy(dto.verifactu_policy),
subtotalAmount: MoneyDTOHelper.toNumber(dto.subtotal_amount),
itemsDiscountAmount: MoneyDTOHelper.toNumber(dto.items_discount_amount),
globalDiscountPercentage: PercentageDTOHelper.toNumber(dto.global_discount_percentage),
@ -52,7 +56,7 @@ export const GetIssuedInvoiceByIdAdapter = {
retentionAmount: MoneyDTOHelper.toNumber(dto.retention_amount),
taxesAmount: MoneyDTOHelper.toNumber(dto.taxes_amount),
totalAmount: MoneyDTOHelper.toNumber(dto.total_amount),
verifactu: mapVerifactu(dto.verifactu),
verifactu: mapVerifactu(dto.verifactu ?? null),
items: dto.items.map(mapItem),
};
},
@ -140,11 +144,46 @@ const mapPaymentTerm = (
};
const mapVerifactu = (
dto: GetIssuedInvoiceByIdResponseDTO["verifactu"]
): IssuedInvoiceVerifactuRecord => {
dto: GetIssuedInvoiceByIdResponseDTO["verifactu"] | null
): IssuedInvoiceVerifactuRecord | null => {
if (dto == null) {
return null;
}
return {
status: dto.status as VerifactuRecordStatus,
url: dto.url,
qrCode: dto.qr_code,
};
};
const mapFiscalClassification = (
dto: GetIssuedInvoiceByIdResponseDTO["fiscal_classification"]
): IssuedInvoiceFiscalClassification | null => {
if (dto == null) {
return null;
}
return {
taxRegimeCode: dto.tax_regime_code,
taxRegimeLabel: dto.tax_regime_label,
operationQualificationCode: dto.operation_qualification_code,
operationQualificationLabel: dto.operation_qualification_label,
exemptionReasonCode: dto.exemption_reason_code,
exemptionReasonLabel: dto.exemption_reason_label,
};
};
const mapVerifactuPolicy = (
dto: GetIssuedInvoiceByIdResponseDTO["verifactu_policy"]
): IssuedInvoiceVerifactuPolicy | null => {
if (dto == null) {
return null;
}
return {
profileStatus: dto.profile_status,
usesVerifactu: dto.uses_verifactu,
effectiveAt: dto.effective_at,
};
};

View File

@ -7,7 +7,9 @@ export interface GetIssuedInvoiceByIdParams {
signal?: AbortSignal;
}
export type GetIssuedInvoiceByIdResult = GetIssuedInvoiceByIdResponseDTO;
export type GetIssuedInvoiceByIdResult = Omit<GetIssuedInvoiceByIdResponseDTO, "verifactu"> & {
verifactu: GetIssuedInvoiceByIdResponseDTO["verifactu"] | null;
};
/**
* Recupera el detalle completo de una factura emitida a partir de su identificador.

View File

@ -59,6 +59,25 @@ export interface IssuedInvoiceVerifactuRecord {
qrCode: string | null;
}
export interface IssuedInvoiceFiscalClassification {
taxRegimeCode: string;
taxRegimeLabel: string | null;
operationQualificationCode: string | null;
operationQualificationLabel: string | null;
exemptionReasonCode: string | null;
exemptionReasonLabel: string | null;
}
export interface IssuedInvoiceVerifactuPolicy {
profileStatus:
| "not_configured"
| "exempt"
| "obligated_without_verifactu"
| "obligated_with_verifactu";
usesVerifactu: boolean;
effectiveAt: string | null;
}
export interface IssuedInvoicePaymentTermDueRule {
dueDays: string;
percentage: {
@ -103,6 +122,8 @@ export interface IssuedInvoice {
paymentTerm: IssuedInvoicePaymentTermDetail | null;
taxRegimeCode: string;
taxRegimeDescription: string | null;
fiscalClassification: IssuedInvoiceFiscalClassification | null;
verifactuPolicy: IssuedInvoiceVerifactuPolicy | null;
subtotalAmount: number;
itemsDiscountAmount: number;
globalDiscountPercentage: number;
@ -114,6 +135,6 @@ export interface IssuedInvoice {
retentionAmount: number;
taxesAmount: number;
totalAmount: number;
verifactu: IssuedInvoiceVerifactuRecord;
verifactu: IssuedInvoiceVerifactuRecord | null;
items: IssuedInvoiceItem[];
}

View File

@ -3,6 +3,12 @@ packages:
- modules/*
- apps/*
allowBuilds:
'@parcel/watcher': false
bcrypt: false
esbuild: false
msw: false
ignoredBuiltDependencies:
- esbuild