diff --git a/server/.env b/server/.env index 24bf0b2..2d6e7b7 100644 --- a/server/.env +++ b/server/.env @@ -69,7 +69,7 @@ FILE_SERVER_URL = http://localhost:4000/ FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/ SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key -ARCA_WSAA_HOMOLOGATION_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms -ARCA_WSFE_HOMOLOGATION_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx +ARCA_WSAA_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms +ARCA_WSFE_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= diff --git a/server/.env.prod b/server/.env.prod index d6680b7..0635964 100644 --- a/server/.env.prod +++ b/server/.env.prod @@ -67,7 +67,7 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997 FILE_SERVER_URL = https://files.turnosxpress.com.ar/ SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key -ARCA_WSAA_HOMOLOGATION_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms -ARCA_WSFE_HOMOLOGATION_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx +ARCA_WSAA_URL=https://wsaa.afip.gov.ar/ws/services/LoginCms +ARCA_WSFE_URL=https://servicios1.afip.gov.ar/wsfev1/service.asmx ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= diff --git a/server/src/Models/ArcaActivities/ArcaActivities.Catalog.ts b/server/src/Models/ArcaActivities/ArcaActivities.Catalog.ts new file mode 100644 index 0000000..4d8d4fe --- /dev/null +++ b/server/src/Models/ArcaActivities/ArcaActivities.Catalog.ts @@ -0,0 +1,134 @@ +import { TextObjectFilterResult } from "../TextObjectFilter.model"; + +export type ArcaActivity = { + code: string; + description: string; +}; + +export const ARCA_ACTIVITIES: ArcaActivity[] = [ + { code: "011111", description: "Cultivo de arroz" }, + { code: "011112", description: "Cultivo de trigo" }, + { code: "011119", description: "Cultivo de cereales n.c.p., excepto los de uso forrajero" }, + { code: "011121", description: "Cultivo de maíz" }, + { code: "011129", description: "Cultivo de cereales de uso forrajero n.c.p." }, + { code: "011130", description: "Cultivo de oleaginosas, excepto soja" }, + { code: "011140", description: "Cultivo de pastos de uso forrajero" }, + { code: "011211", description: "Cultivo de papa, batata y mandioca" }, + { code: "011221", description: "Cultivo de tomate" }, + { code: "011229", description: "Cultivo de bulbos, brotes, raíces y hortalizas de fruto n.c.p." }, + { code: "011240", description: "Cultivo de legumbres" }, + { code: "012110", description: "Cultivo de vid para vinificar" }, + { code: "012121", description: "Cultivo de uva de mesa" }, + { code: "012200", description: "Cultivo de frutas cítricas" }, + { code: "012311", description: "Cultivo de manzana y pera" }, + { code: "012320", description: "Cultivo de frutas de carozo" }, + { code: "012410", description: "Cultivo de frutas tropicales y subtropicales" }, + { code: "012510", description: "Cultivo de frutas secas" }, + { code: "013011", description: "Producción de semillas híbridas de cereales y oleaginosas" }, + { code: "013019", description: "Producción de semillas varietales o autofecundadas de cereales, oleaginosas, y forrajeras" }, + { code: "014113", description: "Cría de ganado bovino, excepto la realizada en cabañas y para la producción de leche" }, + { code: "014114", description: "Invernada de ganado bovino excepto el engorde en corrales (feed-lot)" }, + { code: "014115", description: "Engorde en corrales (feed-lot)" }, + { code: "014211", description: "Cría de ganado equino, excepto la realizada en haras" }, + { code: "014300", description: "Cría de ganado camélido" }, + { code: "014410", description: "Cría de ganado ovino, excepto en cabañas y para la producción de lana" }, + { code: "014420", description: "Cría de ganado ovino realizada en cabañas" }, + { code: "014430", description: "Cría de ganado ovino para la producción de lana" }, + { code: "014510", description: "Cría de ganado porcino, excepto la realizada en cabañas" }, + { code: "014520", description: "Cría de ganado porcino realizada en cabañas" }, + { code: "014610", description: "Producción de leche bovina" }, + { code: "014620", description: "Producción de leche de oveja y de cabra" }, + { code: "014710", description: "Producción de huevos" }, + { code: "014720", description: "Cría de aves para producción de carnes" }, + { code: "014740", description: "Producción de pollitos BB" }, + { code: "014810", description: "Cría de abejas" }, + { code: "014820", description: "Producción de pelos de ganado" }, + { code: "014910", description: "Cría de animales para la obtención de pieles y cueros" }, + { code: "014990", description: "Cría de animales y obtención de productos de origen animal n.c.p." }, + { code: "016111", description: "Servicios de labranza, siembra, transplante y cuidados culturales" }, + { code: "016112", description: "Servicios de pulverización, desinfección y fumigación terrestre" }, + { code: "016113", description: "Servicios de pulverización, desinfección y fumigación aérea" }, + { code: "016119", description: "Servicios de maquinaria agrícola n.c.p., excepto los de cosecha mecánica" }, + { code: "016120", description: "Servicios de cosecha mecánica" }, + { code: "016130", description: "Servicios de contratistas de mano de obra agrícola" }, + { code: "016140", description: "Servicios de post cosecha" }, + { code: "016150", description: "Servicios de procesamiento de semillas para su siembra" }, + { code: "016190", description: "Servicios de apoyo agrícolas n.c.p." }, + { code: "016210", description: "Inseminación artificial y servicios n.c.p. para mejorar la reproducción de los animales y el rendimiento de sus productos" }, + { code: "016220", description: "Servicios de contratistas de mano de obra pecuaria" }, + { code: "016230", description: "Servicios de esquila de animales" }, + { code: "016291", description: "Servicios para el control de plagas, baños parasiticidas, etc." }, + { code: "016292", description: "Albergue y cuidado de animales de terceros" }, + { code: "016299", description: "Servicios de apoyo pecuarios n.c.p." }, + { code: "551010", description: "Servicios de alojamiento por hora" }, + { code: "551021", description: "Servicios de alojamiento en pensiones" }, + { code: "551022", description: "Servicios de alojamiento en hoteles, hosterías y residenciales similares, excepto por hora, que incluyen servicio de restaurante al público" }, + { code: "551023", description: "Servicios de alojamiento en hoteles, hosterías y residenciales similares, excepto por hora, que no incluyen servicio de restaurante al público" }, + { code: "561011", description: "Servicios de restaurantes y cantinas sin espectáculo" }, + { code: "561012", description: "Servicios de restaurantes y cantinas con espectáculo" }, + { code: "561013", description: "Servicios de fast food y locales de venta de comidas y bebidas al paso" }, + { code: "561014", description: "Servicios de expendio de bebidas en bares" }, + { code: "561019", description: "Servicios de expendio de comidas y bebidas en establecimientos con servicio de mesa y/o en mostrador n.c.p." }, + { code: "620100", description: "Servicios de consultores en informática y suministros de programas de informática" }, + { code: "620200", description: "Servicios de consultores en equipo de informática" }, + { code: "620300", description: "Servicios de consultores en tecnología de la información" }, + { code: "620900", description: "Servicios de informática n.c.p." }, + { code: "681098", description: "Servicios inmobiliarios realizados por cuenta propia, con bienes urbanos propios o arrendados n.c.p." }, + { code: "681099", description: "Servicios inmobiliarios realizados por cuenta propia, con bienes rurales propios o arrendados n.c.p." }, + { code: "691001", description: "Servicios jurídicos" }, + { code: "692000", description: "Servicios de contabilidad, auditoría y asesoría fiscal" }, + { code: "702091", description: "Servicios de asesoramiento, dirección y gestión empresarial realizados por integrantes de los órganos de administración y/o fiscalización en sociedades anónimas" }, + { code: "702092", description: "Servicios de asesoramiento, dirección y gestión empresarial realizados por integrantes de cuerpos de dirección en sociedades excepto las anónimas" }, + { code: "702099", description: "Servicios de asesoramiento, dirección y gestión empresarial n.c.p." }, + { code: "749009", description: "Actividades profesionales, científicas y técnicas n.c.p." }, + { code: "851010", description: "Guarderías y jardines maternales" }, + { code: "851020", description: "Enseñanza inicial, jardín de infantes y primaria" }, + { code: "852100", description: "Enseñanza secundaria de formación general" }, + { code: "852200", description: "Enseñanza secundaria de formación técnica y profesional" }, + { code: "853100", description: "Enseñanza terciaria" }, + { code: "853201", description: "Enseñanza universitaria excepto formación de posgrado" }, + { code: "853300", description: "Formación de posgrado" }, + { code: "854910", description: "Enseñanza de idiomas" }, + { code: "854920", description: "Enseñanza de cursos relacionados con informática" }, + { code: "854930", description: "Enseñanza para adultos, excepto discapacitados" }, + { code: "854940", description: "Enseñanza especial y para discapacitados" }, + { code: "854950", description: "Enseñanza de gimnasia, deportes y actividades físicas" }, + { code: "854960", description: "Enseñanza artística" }, + { code: "854990", description: "Servicios de enseñanza n.c.p." }, + { code: "855000", description: "Servicios de apoyo a la educación" }, + { code: "861010", description: "Servicios de internación" }, + { code: "862110", description: "Servicios de consulta médica" }, + { code: "862120", description: "Servicios de atención médica en dispensarios, salitas, vacunatorios y otros locales de atención primaria de la salud" }, + { code: "862130", description: "Servicios de atención médica en otros establecimientos n.c.p." }, + { code: "862200", description: "Servicios odontológicos" }, + { code: "869010", description: "Servicios de prácticas de diagnóstico en laboratorios" }, + { code: "869090", description: "Servicios relacionados con la salud humana n.c.p." }, + { code: "900011", description: "Producción de espectáculos teatrales y musicales" }, + { code: "900021", description: "Composición y representación de obras teatrales, musicales y artísticas" }, + { code: "931010", description: "Servicios de organización, dirección y gestión de prácticas deportivas y explotación de las instalaciones" }, + { code: "931020", description: "Servicios prestados por profesionales y técnicos para la realización de prácticas deportivas" }, + { code: "960201", description: "Servicios de peluquería" }, + { code: "960202", description: "Servicios de tratamiento de belleza, excepto los de peluquería" }, + { code: "960990", description: "Servicios personales n.c.p." }, +]; + +export const findArcaActivityByCode = (code: string): ArcaActivity | undefined => { + return ARCA_ACTIVITIES.find((activity) => activity.code === code.trim()); +}; + +export const filterArcaActivities = (name?: string): TextObjectFilterResult[] => { + const normalizedFilter = (name || "").trim().toLowerCase(); + + return ARCA_ACTIVITIES.filter((activity) => { + if (!normalizedFilter) return true; + + return ( + activity.code.includes(normalizedFilter) || + activity.description.toLowerCase().includes(normalizedFilter) + ); + }).map((activity) => ({ + id: activity.code, + name: activity.description, + description: activity.code, + })); +}; diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts index f7d158e..98cc8da 100644 --- a/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts @@ -49,6 +49,11 @@ export type ArcaWsfeIssueFacturaCParams = { companyId: string; amount: number; billingDate: Date; + servicePeriodFrom: Date; + servicePeriodTo: Date; + paymentDueDate: Date; + receiverDocumentType?: number | string; + receiverDocumentNumber?: number | string; sessionUser: string; }; diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.ts index 8b51139..058cb2e 100644 --- a/server/src/Models/ArcaCredentials/ArcaCredentials.ts +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.ts @@ -177,6 +177,29 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { }; } + private escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + private parseXmlInteger(value: number | string, fieldName: string): number { + if (typeof value === "string" && value.trim() === "") { + throw new Error(`${fieldName} debe ser un número entero válido`); + } + + const parsedValue = typeof value === "string" ? Number(value.trim()) : value; + + if (!Number.isInteger(parsedValue) || !Number.isFinite(parsedValue) || parsedValue < 0) { + throw new Error(`${fieldName} debe ser un número entero válido`); + } + + return parsedValue; + } + private signTraCms(traXml: string, certificatePem: string, privateKeyPem: string): string { const p7 = forge.pkcs7.createSignedData(); p7.content = forge.util.createBuffer(traXml, "utf8"); @@ -198,7 +221,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { } private buildLoginCmsSoap(cms: string): string { - return `\n\n\n\n\n${cms}\n\n\n`; + return `\n\n\n\n\n${this.escapeXml(cms)}\n\n\n`; } private extractXmlValue(xml: string, tagName: string): string | undefined { @@ -271,10 +294,10 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { const privateKeyPem = this.decryptPrivateKey(credential); const { traXml } = this.buildTraXml(service); const cms = this.signTraCms(traXml, credential.certificatePem, privateKeyPem); - const wsaaUrl = process.env.ARCA_WSAA_HOMOLOGATION_URL; + const wsaaUrl = process.env.ARCA_WSAA_URL; if (!wsaaUrl) { - throw new Error("ARCA_WSAA_HOMOLOGATION_URL no está configurada"); + throw new Error("ARCA_WSAA_URL no está configurada"); } let response; @@ -315,18 +338,21 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { pointOfSale: number; voucherType: 11; }): string { + const pointOfSale = this.parseXmlInteger(data.pointOfSale, "PtoVta"); + const voucherType = this.parseXmlInteger(data.voucherType, "CbteTipo"); + return ` - ${data.token} - ${data.sign} - ${data.cuit} + ${this.escapeXml(data.token)} + ${this.escapeXml(data.sign)} + ${this.escapeXml(data.cuit)} - ${data.pointOfSale} - ${data.voucherType} + ${pointOfSale} + ${voucherType} `; @@ -347,9 +373,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { private parseArcaDate(value: string): Date { const year = Number(value.slice(0, 4)); - const month = Number(value.slice(4, 6)) - 1; + const month = Number(value.slice(4, 6)); const day = Number(value.slice(6, 8)); - return new Date(year, month, day); + return new Date(Date.UTC(year, month - 1, day, 12)); } private buildWsfeIssueFacturaCSoap(data: { @@ -360,8 +386,17 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { voucherNumber: number; amount: number; voucherDate: string; + servicePeriodFrom: string; + servicePeriodTo: string; + paymentDueDate: string; + receiverDocumentType: number | string; + receiverDocumentNumber: number | string; }): string { const amount = data.amount.toFixed(2); + const pointOfSale = this.parseXmlInteger(data.pointOfSale, "PtoVta"); + const voucherNumber = this.parseXmlInteger(data.voucherNumber, "CbteDesde"); + const receiverDocumentType = this.parseXmlInteger(data.receiverDocumentType, "DocTipo"); + const receiverDocumentNumber = this.parseXmlInteger(data.receiverDocumentNumber, "DocNro"); return ` @@ -369,23 +404,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { -${data.token} -${data.sign} -${data.cuit} +${this.escapeXml(data.token)} +${this.escapeXml(data.sign)} +${this.escapeXml(data.cuit)} 1 -${data.pointOfSale} +${pointOfSale} 11 -1 -${FINAL_CONSUMER_DOCUMENT_TYPE} -${FINAL_CONSUMER_DOCUMENT_NUMBER} -${data.voucherNumber} -${data.voucherNumber} +3 +${receiverDocumentType} +${receiverDocumentNumber} +${voucherNumber} +${voucherNumber} ${data.voucherDate} ${amount} 0.00 @@ -393,6 +428,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { 0.00 0.00 0.00 +${data.servicePeriodFrom} +${data.servicePeriodTo} +${data.paymentDueDate} PES 1 ${FINAL_CONSUMER_IVA_CONDITION} @@ -421,9 +459,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { - ${data.token} - ${data.sign} - ${data.cuit} + ${this.escapeXml(data.token)} + ${this.escapeXml(data.sign)} + ${this.escapeXml(data.cuit)} @@ -437,9 +475,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { - ${data.token} - ${data.sign} - ${data.cuit} + ${this.escapeXml(data.token)} + ${this.escapeXml(data.sign)} + ${this.escapeXml(data.cuit)} @@ -447,10 +485,10 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { } private async postWsfe(soapBody: string, soapAction: string): Promise { - const wsfeUrl = process.env.ARCA_WSFE_HOMOLOGATION_URL; + const wsfeUrl = process.env.ARCA_WSFE_URL; if (!wsfeUrl) { - throw new Error("ARCA_WSFE_HOMOLOGATION_URL no está configurada"); + throw new Error("ARCA_WSFE_URL no está configurada"); } const response = await axios.post(wsfeUrl, soapBody, { @@ -613,7 +651,12 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { return { ok: false, configuredPointOfSale, error: errorMessage }; } - const points = Array.from(xml.matchAll(/<(?:\w+:)?PtoVenta>(\d+)<\/(?:\w+:)?PtoVenta>/g)).map((match) => Number(match[1])); + const points = Array.from(xml.matchAll(/<(?:\w+:)?PtoVenta(?:\s[^>]*)?>([\s\S]*?)<\/(?:\w+:)?PtoVenta>/g)) + .map((match) => { + const nestedNumber = this.extractXmlValue(match[1], "Nro"); + return Number(nestedNumber || match[1].trim()); + }) + .filter((point) => Number.isInteger(point)); return { ok: true, @@ -817,6 +860,14 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const loginTicket = await this.getWsaaLoginTicket(data.companyId); const voucherType = 11; + const receiverDocumentType = this.parseXmlInteger( + data.receiverDocumentType ?? FINAL_CONSUMER_DOCUMENT_TYPE, + "DocTipo" + ); + const receiverDocumentNumber = this.parseXmlInteger( + data.receiverDocumentNumber ?? FINAL_CONSUMER_DOCUMENT_NUMBER, + "DocNro" + ); let lastVoucherNumber: number; try { @@ -848,6 +899,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager { voucherNumber, amount: data.amount, voucherDate: this.formatArcaDate(data.billingDate), + servicePeriodFrom: this.formatArcaDate(data.servicePeriodFrom), + servicePeriodTo: this.formatArcaDate(data.servicePeriodTo), + paymentDueDate: this.formatArcaDate(data.paymentDueDate), + receiverDocumentType, + receiverDocumentNumber, }), "FECAESolicitar" ), diff --git a/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts index 94bf1c5..6add5f5 100644 --- a/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts +++ b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts @@ -42,8 +42,25 @@ export class InvoicesAdapterMongoose implements IInvoicesAdapter { type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) }, status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true }, billingDate: { type: Date, required: true }, - paymentMethod: { type: String, required: true }, - description: { type: String, required: true }, + servicePeriodFrom: { type: Date, required: true }, + servicePeriodTo: { type: Date, required: true }, + paymentDueDate: { type: Date, required: true }, + activityCode: { type: String, required: true }, + activityDescription: { type: String, required: true }, + receiverIvaCondition: { type: Number, required: true, default: 5 }, + receiverDocumentType: { type: Schema.Types.Mixed, required: false }, + receiverDocumentNumber: { type: Schema.Types.Mixed, required: false }, + receiverName: { type: String, required: false }, + receiverAddress: { type: String, required: false }, + receiverEmail: { type: String, required: false }, + saleCondition: { type: String, required: true }, + itemDescription: { type: String, required: true }, + itemQuantity: { type: Number, required: true, default: 1 }, + itemUnit: { type: String, required: false }, + itemUnitPrice: { type: Number, required: true }, + itemDiscountPercent: { type: Number, required: true, default: 0 }, + itemDiscountAmount: { type: Number, required: true, default: 0 }, + itemSubtotal: { type: Number, required: true }, cae: { type: String, required: false }, caeExpiresAt: { type: Date, required: false }, pointOfSale: { type: Number, required: false }, diff --git a/server/src/Models/Invoices/Invoices.Interface.ts b/server/src/Models/Invoices/Invoices.Interface.ts index e925bef..fbbe72b 100644 --- a/server/src/Models/Invoices/Invoices.Interface.ts +++ b/server/src/Models/Invoices/Invoices.Interface.ts @@ -12,19 +12,42 @@ export enum INVOICE_STATUS { FAILED = "FAILED", } -export enum INVOICE_PAYMENT_METHOD { +export enum INVOICE_SALE_CONDITION { CASH = "cash", + CONTADO = "contado", BANK_TRANSFER = "bank_transfer", CREDIT_CARD = "credit_card", DEBIT_CARD = "debit_card", + CURRENT_ACCOUNT = "current_account", + CHECK = "check", OTHER = "other", + ELECTRONIC_PAYMENT = "electronic_payment", } +export const FINAL_CONSUMER_IVA_CONDITION_ID = 5; + export type CreateInvoiceFromCashMovementParams = { cashMovementId: string; billingDate: Date | string; - paymentMethod: INVOICE_PAYMENT_METHOD | string; - description: string; + servicePeriodFrom: Date | string; + servicePeriodTo: Date | string; + paymentDueDate: Date | string; + activityCode: string; + activityDescription: string; + receiverIvaCondition?: 5; + receiverDocumentType?: number | string; + receiverDocumentNumber?: number | string; + receiverName?: string; + receiverAddress?: string; + receiverEmail?: string; + saleCondition: INVOICE_SALE_CONDITION | string; + itemDescription: string; + itemQuantity?: number; + itemUnit?: string; + itemUnitPrice: number; + itemDiscountPercent?: number; + itemDiscountAmount?: number; + itemSubtotal: number; sessionUser: string; }; @@ -48,8 +71,25 @@ export type CreateInvoiceParams = { type: INVOICE_TYPE; status: INVOICE_STATUS; billingDate: Date; - paymentMethod: INVOICE_PAYMENT_METHOD | string; - description: string; + servicePeriodFrom: Date; + servicePeriodTo: Date; + paymentDueDate: Date; + activityCode: string; + activityDescription: string; + receiverIvaCondition: 5; + receiverDocumentType?: number | string; + receiverDocumentNumber?: number | string; + receiverName?: string; + receiverAddress?: string; + receiverEmail?: string; + saleCondition: INVOICE_SALE_CONDITION | string; + itemDescription: string; + itemQuantity: number; + itemUnit?: string; + itemUnitPrice: number; + itemDiscountPercent: number; + itemDiscountAmount: number; + itemSubtotal: number; cae?: string; caeExpiresAt?: Date; pointOfSale?: number; @@ -75,8 +115,25 @@ export interface IInvoice { type: INVOICE_TYPE; status: INVOICE_STATUS; billingDate: Date; - paymentMethod: INVOICE_PAYMENT_METHOD | string; - description: string; + servicePeriodFrom: Date; + servicePeriodTo: Date; + paymentDueDate: Date; + activityCode: string; + activityDescription: string; + receiverIvaCondition: 5; + receiverDocumentType?: number | string; + receiverDocumentNumber?: number | string; + receiverName?: string; + receiverAddress?: string; + receiverEmail?: string; + saleCondition: INVOICE_SALE_CONDITION | string; + itemDescription: string; + itemQuantity: number; + itemUnit?: string; + itemUnitPrice: number; + itemDiscountPercent: number; + itemDiscountAmount: number; + itemSubtotal: number; cae?: string; caeExpiresAt?: Date; pointOfSale?: number; diff --git a/server/src/Models/Invoices/Invoices.ts b/server/src/Models/Invoices/Invoices.ts index 9a6ede5..43486b3 100644 --- a/server/src/Models/Invoices/Invoices.ts +++ b/server/src/Models/Invoices/Invoices.ts @@ -12,17 +12,24 @@ import { CreateInvoiceFromCashMovementParams, CreateInvoiceParams, FindInvoicesParams, + FINAL_CONSUMER_IVA_CONDITION_ID, IInvoice, IInvoicesManager, - INVOICE_PAYMENT_METHOD, + INVOICE_SALE_CONDITION, INVOICE_STATUS, INVOICE_TYPE, } from "./Invoices.Interface"; const FACTURA_C_VOUCHER_TYPE = "11"; const ARGENTINA_TIME_ZONE = "America/Argentina/Buenos_Aires"; -const DESCRIPTION_MAX_LENGTH = 500; +const TEXT_MAX_LENGTH = 500; +const RECEIVER_TEXT_MAX_LENGTH = 200; +const CENTS_TOLERANCE = 0.01; const YYYY_MM_DD_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const DNI_DOCUMENT_TYPE = 96; +const FINAL_CONSUMER_DOCUMENT_TYPE = 99; +const FINAL_CONSUMER_DOCUMENT_NUMBER = 0; +const FINAL_CONSUMER_COMPATIBLE_DOCUMENT_TYPES = new Set([DNI_DOCUMENT_TYPE, FINAL_CONSUMER_DOCUMENT_TYPE]); class InvoicesManager implements IInvoicesManager { invoices: InvoicesAdapterMongoose; @@ -65,30 +72,51 @@ class InvoicesManager implements IInvoicesManager { return `${year}-${month}-${day}`; } - private parseBillingDate(billingDate: Date | string): Date { - if (!billingDate) { - throw new Error("Se requiere la fecha del comprobante"); + private parseArgentinaCalendarDate(dateValue: Date | string, requiredMessage: string, invalidMessage: string): Date { + if (!dateValue) { + throw new Error(requiredMessage); } let parsedDate: Date; - if (typeof billingDate === "string") { - const localDateMatch = billingDate.match(YYYY_MM_DD_PATTERN); + if (typeof dateValue === "string") { + const localDateMatch = dateValue.match(YYYY_MM_DD_PATTERN); - if (localDateMatch) { - const [, year, month, day] = localDateMatch; - parsedDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12)); - } else { - parsedDate = new Date(billingDate); + if (!localDateMatch) { + throw new Error(invalidMessage); + } + + const [, year, month, day] = localDateMatch; + const yearNumber = Number(year); + const monthNumber = Number(month); + const dayNumber = Number(day); + parsedDate = new Date(Date.UTC(yearNumber, monthNumber - 1, dayNumber, 12)); + + if ( + parsedDate.getUTCFullYear() !== yearNumber || + parsedDate.getUTCMonth() !== monthNumber - 1 || + parsedDate.getUTCDate() !== dayNumber + ) { + throw new Error(invalidMessage); } } else { - parsedDate = billingDate; + parsedDate = dateValue; } if (Number.isNaN(parsedDate.getTime())) { - throw new Error("La fecha del comprobante no es válida"); + throw new Error(invalidMessage); } + return parsedDate; + } + + private parseBillingDate(billingDate: Date | string): Date { + const parsedDate = this.parseArgentinaCalendarDate( + billingDate, + "Se requiere la fecha del comprobante", + "La fecha del comprobante no es válida" + ); + const selectedDate = this.formatArgentinaCalendarDate(parsedDate); const todayDate = this.formatArgentinaCalendarDate(new Date()); @@ -99,12 +127,66 @@ class InvoicesManager implements IInvoicesManager { return parsedDate; } - private validatePaymentMethod(paymentMethod: string): INVOICE_PAYMENT_METHOD { - if (!Object.values(INVOICE_PAYMENT_METHOD).includes(paymentMethod as INVOICE_PAYMENT_METHOD)) { - throw new Error("El medio de pago de la factura no es válido"); + private validateSaleCondition(saleCondition: string): INVOICE_SALE_CONDITION { + if (!Object.values(INVOICE_SALE_CONDITION).includes(saleCondition as INVOICE_SALE_CONDITION)) { + throw new Error("La condición de venta de la factura no es válida"); } - return paymentMethod as INVOICE_PAYMENT_METHOD; + return saleCondition as INVOICE_SALE_CONDITION; + } + + private trimOptional(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; + } + + private parseReceiverDocumentCode(value: number | string, fieldName: string): number { + if (typeof value === "string" && value.trim() === "") { + throw new Error(`${fieldName} del receptor no es válido`); + } + + const parsedValue = typeof value === "string" ? Number(value.trim()) : value; + + if (!Number.isInteger(parsedValue) || !Number.isFinite(parsedValue) || parsedValue < 0) { + throw new Error(`${fieldName} del receptor no es válido`); + } + + return parsedValue; + } + + private validateReceiverDocumentPair(data: CreateInvoiceFromCashMovementParams): { + receiverDocumentType: number; + receiverDocumentNumber: number; + } { + const hasDocumentType = data.receiverDocumentType !== undefined && data.receiverDocumentType !== null && data.receiverDocumentType !== ""; + const hasDocumentNumber = data.receiverDocumentNumber !== undefined && data.receiverDocumentNumber !== null && data.receiverDocumentNumber !== ""; + + if (hasDocumentType !== hasDocumentNumber) { + throw new Error("Tipo y número de documento del receptor deben informarse juntos"); + } + + if (!hasDocumentType && !hasDocumentNumber) { + return { + receiverDocumentType: FINAL_CONSUMER_DOCUMENT_TYPE, + receiverDocumentNumber: FINAL_CONSUMER_DOCUMENT_NUMBER, + }; + } + + const receiverDocumentType = this.parseReceiverDocumentCode(data.receiverDocumentType as number | string, "El tipo de documento"); + const receiverDocumentNumber = this.parseReceiverDocumentCode(data.receiverDocumentNumber as number | string, "El número de documento"); + + if (!FINAL_CONSUMER_COMPATIBLE_DOCUMENT_TYPES.has(receiverDocumentType)) { + throw new Error("El tipo de documento del receptor no es compatible con Consumidor Final"); + } + + if (receiverDocumentType === FINAL_CONSUMER_DOCUMENT_TYPE && receiverDocumentNumber !== FINAL_CONSUMER_DOCUMENT_NUMBER) { + throw new Error("El número de documento debe ser 0 cuando el tipo es Consumidor Final"); + } + + return { + receiverDocumentType, + receiverDocumentNumber, + }; } public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise { @@ -115,21 +197,71 @@ class InvoicesManager implements IInvoicesManager { } const billingDate = this.parseBillingDate(data.billingDate); - const description = data.description?.trim(); + const servicePeriodFrom = this.parseArgentinaCalendarDate( + data.servicePeriodFrom, + "Se requiere la fecha de inicio del período facturado", + "La fecha de inicio del período facturado no es válida" + ); + const servicePeriodTo = this.parseArgentinaCalendarDate( + data.servicePeriodTo, + "Se requiere la fecha de fin del período facturado", + "La fecha de fin del período facturado no es válida" + ); + const paymentDueDate = this.parseArgentinaCalendarDate( + data.paymentDueDate, + "Se requiere el vencimiento de pago", + "El vencimiento de pago no es válido" + ); + const servicePeriodFromValue = this.formatArgentinaCalendarDate(servicePeriodFrom); + const servicePeriodToValue = this.formatArgentinaCalendarDate(servicePeriodTo); + const billingDateValue = this.formatArgentinaCalendarDate(billingDate); + const paymentDueDateValue = this.formatArgentinaCalendarDate(paymentDueDate); - if (!description) { - throw new Error("Se requiere el detalle de la factura"); + if (servicePeriodFromValue > servicePeriodToValue) { + throw new Error("El inicio del período facturado no puede ser posterior al fin"); } - if (description.length > DESCRIPTION_MAX_LENGTH) { - throw new Error(`El detalle de la factura no puede superar los ${DESCRIPTION_MAX_LENGTH} caracteres`); + if (paymentDueDateValue < billingDateValue) { + throw new Error("El vencimiento de pago no puede ser anterior a la fecha del comprobante"); } - if (!data.paymentMethod || !data.paymentMethod.trim()) { - throw new Error("Se requiere el medio de pago de la factura"); + const activityCode = data.activityCode?.trim(); + const activityDescription = data.activityDescription?.trim(); + const itemDescription = data.itemDescription?.trim(); + + if (!activityCode) { + throw new Error("Se requiere el código de actividad"); } - const paymentMethod = this.validatePaymentMethod(data.paymentMethod.trim()); + if (!activityDescription) { + throw new Error("Se requiere la descripción de la actividad"); + } + + if (!itemDescription) { + throw new Error("Se requiere la descripción del ítem facturado"); + } + + if ([activityCode, activityDescription, itemDescription].some((value) => value.length > TEXT_MAX_LENGTH)) { + throw new Error(`Los datos de actividad e ítem no pueden superar los ${TEXT_MAX_LENGTH} caracteres`); + } + + if (!data.saleCondition || !data.saleCondition.trim()) { + throw new Error("Se requiere la condición de venta"); + } + + const saleCondition = this.validateSaleCondition(data.saleCondition.trim()); + const receiverDocument = this.validateReceiverDocumentPair(data); + const itemQuantity = data.itemQuantity ?? 1; + const itemDiscountPercent = data.itemDiscountPercent ?? 0; + const itemDiscountAmount = data.itemDiscountAmount ?? 0; + + if (!Number.isFinite(data.itemUnitPrice) || !Number.isFinite(data.itemSubtotal)) { + throw new Error("El importe del ítem no es válido"); + } + + if (itemQuantity !== 1 || data.itemUnitPrice <= 0 || itemDiscountPercent !== 0 || itemDiscountAmount !== 0) { + throw new Error("El MVP permite un único ítem de servicio sin descuentos por el total del movimiento"); + } const movement = await CashFlow.movements.movementList.findOne({ _id: data.cashMovementId, @@ -147,6 +279,17 @@ class InvoicesManager implements IInvoicesManager { throw new Error("El movimiento de caja no es facturable"); } + const expectedSubtotal = Number((itemQuantity * data.itemUnitPrice - itemDiscountAmount).toFixed(2)); + const sentSubtotal = Number(data.itemSubtotal.toFixed(2)); + const movementAmount = Number(movement.amount.toFixed(2)); + + if ( + Math.abs(expectedSubtotal - sentSubtotal) > CENTS_TOLERANCE || + Math.abs(sentSubtotal - movementAmount) > CENTS_TOLERANCE + ) { + throw new Error("El total del ítem debe coincidir con el importe del movimiento"); + } + const fiscalProfiles = await OrganizationFiscalProfiles.find({ companyId, status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE, @@ -195,8 +338,25 @@ class InvoicesManager implements IInvoicesManager { type: INVOICE_TYPE.FACTURA_C, status: INVOICE_STATUS.PENDING_ARCA, billingDate, - paymentMethod, - description, + servicePeriodFrom, + servicePeriodTo, + paymentDueDate, + activityCode, + activityDescription, + receiverIvaCondition: FINAL_CONSUMER_IVA_CONDITION_ID, + receiverDocumentType: receiverDocument.receiverDocumentType, + receiverDocumentNumber: receiverDocument.receiverDocumentNumber, + receiverName: this.trimOptional(data.receiverName)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + receiverAddress: this.trimOptional(data.receiverAddress)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + receiverEmail: this.trimOptional(data.receiverEmail)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + saleCondition, + itemDescription, + itemQuantity, + itemUnit: this.trimOptional(data.itemUnit), + itemUnitPrice: data.itemUnitPrice, + itemDiscountPercent, + itemDiscountAmount, + itemSubtotal: sentSubtotal, createdAt: new Date(), updatedAt: new Date(), }; @@ -233,8 +393,25 @@ class InvoicesManager implements IInvoicesManager { invoice = await this.invoices.update({ cashMovementId: data.cashMovementId, billingDate, - paymentMethod, - description, + servicePeriodFrom, + servicePeriodTo, + paymentDueDate, + activityCode, + activityDescription, + receiverIvaCondition: FINAL_CONSUMER_IVA_CONDITION_ID, + receiverDocumentType: receiverDocument.receiverDocumentType, + receiverDocumentNumber: receiverDocument.receiverDocumentNumber, + receiverName: this.trimOptional(data.receiverName)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + receiverAddress: this.trimOptional(data.receiverAddress)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + receiverEmail: this.trimOptional(data.receiverEmail)?.slice(0, RECEIVER_TEXT_MAX_LENGTH), + saleCondition, + itemDescription, + itemQuantity, + itemUnit: this.trimOptional(data.itemUnit), + itemUnitPrice: data.itemUnitPrice, + itemDiscountPercent, + itemDiscountAmount, + itemSubtotal: sentSubtotal, updatedAt: new Date(), }) || invoice; } @@ -255,6 +432,11 @@ class InvoicesManager implements IInvoicesManager { companyId, amount: movement.amount, billingDate, + servicePeriodFrom, + servicePeriodTo, + paymentDueDate, + receiverDocumentType: receiverDocument.receiverDocumentType, + receiverDocumentNumber: receiverDocument.receiverDocumentNumber, sessionUser: data.sessionUser, }); diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts index 5b38d50..5749f62 100644 --- a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts @@ -34,6 +34,8 @@ export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationF enum: Object.values(ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION), }, pointOfSale: { type: Number, required: true }, + activityCode: { type: String, required: false }, + activityDescription: { type: String, required: false }, status: { type: String, required: true, diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts index 4b3b8f3..e489e2b 100644 --- a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts @@ -17,6 +17,8 @@ export type UpsertOrganizationFiscalProfileParams = { legalName: string; taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION; pointOfSale: number; + activityCode?: string; + activityDescription?: string; sessionUser: string; }; @@ -33,6 +35,8 @@ export type CreateOrganizationFiscalProfileParams = { legalName: string; taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION; pointOfSale: number; + activityCode?: string; + activityDescription?: string; status: ORGANIZATION_FISCAL_PROFILE_STATUS; createdAt: Date; updatedAt: Date; @@ -43,6 +47,8 @@ export type UpdateOrganizationFiscalProfileParams = FindOrganizationFiscalProfil legalName?: string; taxCondition?: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION; pointOfSale?: number; + activityCode?: string; + activityDescription?: string; status?: ORGANIZATION_FISCAL_PROFILE_STATUS; updatedAt: Date; }; @@ -54,6 +60,8 @@ export interface IOrganizationFiscalProfile { legalName: string; taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION; pointOfSale: number; + activityCode?: string; + activityDescription?: string; status: ORGANIZATION_FISCAL_PROFILE_STATUS; createdAt: Date; updatedAt: Date; diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts index 78c1c9a..5d7f037 100644 --- a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts @@ -1,4 +1,5 @@ import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; +import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog"; import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose"; import { FindOrganizationFiscalProfilesParams, @@ -31,6 +32,31 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa if (!data.pointOfSale || data.pointOfSale <= 0) { throw new Error("El punto de venta debe ser mayor a 0"); } + + const activityCode = data.activityCode?.trim(); + + if (activityCode && !/^\d{6}$/.test(activityCode)) { + throw new Error("El código de actividad debe contener 6 dígitos"); + } + + if (activityCode && !findArcaActivityByCode(activityCode)) { + throw new Error("La actividad ARCA/CLAE seleccionada no existe en el catálogo"); + } + } + + private buildProfileData(data: UpsertOrganizationFiscalProfileParams) { + const activityCode = data.activityCode?.trim(); + const activity = activityCode ? findArcaActivityByCode(activityCode) : undefined; + + return { + cuit: data.cuit, + legalName: data.legalName.trim(), + taxCondition: data.taxCondition, + pointOfSale: data.pointOfSale, + activityCode: activity?.code || "", + activityDescription: activity?.description || "", + status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE, + }; } public async upsert( @@ -51,15 +77,12 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa }); const now = new Date(); + const profileData = this.buildProfileData(data); if (existingProfile) { const updatedProfile = await this.organizationFiscalProfiles.update({ companyId: data.companyId, - cuit: data.cuit, - legalName: data.legalName.trim(), - taxCondition: data.taxCondition, - pointOfSale: data.pointOfSale, - status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE, + ...profileData, updatedAt: now, }); @@ -73,11 +96,7 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa try { return await this.organizationFiscalProfiles.create({ companyId: data.companyId, - cuit: data.cuit, - legalName: data.legalName.trim(), - taxCondition: data.taxCondition, - pointOfSale: data.pointOfSale, - status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE, + ...profileData, createdAt: now, updatedAt: now, }); @@ -85,11 +104,7 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa if (this.isDuplicateKeyError(error)) { const updatedProfile = await this.organizationFiscalProfiles.update({ companyId: data.companyId, - cuit: data.cuit, - legalName: data.legalName.trim(), - taxCondition: data.taxCondition, - pointOfSale: data.pointOfSale, - status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE, + ...profileData, updatedAt: new Date(), }); diff --git a/server/src/api/ArcaActivities/ArcaActivities.Controller.ts b/server/src/api/ArcaActivities/ArcaActivities.Controller.ts new file mode 100644 index 0000000..0211644 --- /dev/null +++ b/server/src/api/ArcaActivities/ArcaActivities.Controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { ArcaActivitiesService, FilterArcaActivitiesParams } from "./ArcaActivities.Service"; + +@Route("arca-activities/filter-all") +@Middlewares(authenticateMiddleware) +export class ArcaActivitiesFilterAllController extends Controller { + @Response(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async allArcaActivities( + @Body() requestBody: FilterArcaActivitiesParams + ): Promise { + try { + const activities = await new ArcaActivitiesService().textObjectFilter(requestBody); + this.setStatus(200); + return activities; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/ArcaActivities/ArcaActivities.Service.ts b/server/src/api/ArcaActivities/ArcaActivities.Service.ts new file mode 100644 index 0000000..258d619 --- /dev/null +++ b/server/src/api/ArcaActivities/ArcaActivities.Service.ts @@ -0,0 +1,12 @@ +import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model"; +import { filterArcaActivities } from "../../Models/ArcaActivities/ArcaActivities.Catalog"; + +export type FilterArcaActivitiesParams = { + name?: string; +}; + +export class ArcaActivitiesService { + public async textObjectFilter(data: FilterArcaActivitiesParams): Promise { + return filterArcaActivities(data.name); + } +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx index c10ce74..d058aaf 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx @@ -29,8 +29,25 @@ export type ArcaWsfeLastVoucherResult = { export type CreateInvoiceFromCashMovementParams = { cashMovementId: string; billingDate: string | Date; - paymentMethod: string; - description: string; + servicePeriodFrom: string | Date; + servicePeriodTo: string | Date; + paymentDueDate: string | Date; + activityCode: string; + activityDescription: string; + receiverIvaCondition?: 5; + receiverDocumentType?: string | number; + receiverDocumentNumber?: string | number; + receiverName?: string; + receiverAddress?: string; + receiverEmail?: string; + saleCondition: string; + itemDescription: string; + itemQuantity?: number; + itemUnit?: string; + itemUnitPrice: number; + itemDiscountPercent?: number; + itemDiscountAmount?: number; + itemSubtotal: number; sessionUser: string; }; @@ -51,8 +68,25 @@ export type Invoice = { type: "FACTURA_C"; status: InvoiceStatus; billingDate: Date; - paymentMethod: string; - description: string; + servicePeriodFrom: Date; + servicePeriodTo: Date; + paymentDueDate: Date; + activityCode: string; + activityDescription: string; + receiverIvaCondition: 5; + receiverDocumentType?: string | number; + receiverDocumentNumber?: string | number; + receiverName?: string; + receiverAddress?: string; + receiverEmail?: string; + saleCondition: string; + itemDescription: string; + itemQuantity: number; + itemUnit?: string; + itemUnitPrice: number; + itemDiscountPercent: number; + itemDiscountAmount: number; + itemSubtotal: number; cae?: string; caeExpiresAt?: Date; pointOfSale?: number; @@ -61,6 +95,20 @@ export type Invoice = { arcaErrorMessage?: string; }; +export type OrganizationFiscalProfile = { + cuit?: string; + legalName?: string; + taxCondition?: "MONOTRIBUTO" | "EXENTO"; + pointOfSale?: number; + activityCode?: string; + activityDescription?: string; +}; + +export type FindOrganizationFiscalProfilesParams = { + companyId: string; + sessionUser: string; +}; + const schemaCreateCashFlowMovement = Yup.object().shape({ paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."), companyId: Yup.string().required("La organización no es valida."), @@ -136,3 +184,9 @@ export const createInvoiceFromCashMovement = async ( export const findInvoices = async (data: FindInvoicesParams): Promise => { return ApiRequest.post("invoices/find", data); }; + +export const findOrganizationFiscalProfiles = async ( + data: FindOrganizationFiscalProfilesParams +): Promise => { + return ApiRequest.post("organization-fiscal-profiles/find", data); +}; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css index 87f3542..9e794c1 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.module.css @@ -138,6 +138,7 @@ .movementActionsSlot { display: flex; + gap: 8px; margin-top: 16px; } @@ -167,6 +168,17 @@ padding: 7px 10px; } +.movementInvoicePdfButton { + background: var(--white); + border: 1px solid rgba(22, 128, 63, 0.24); + border-radius: 999px; + color: #166534; + cursor: pointer; + font-size: 12px; + font-weight: 800; + padding: 7px 10px; +} + .emptyState { background: var(--white); border: 1px dashed var(--gray-light); @@ -188,7 +200,7 @@ display: flex; inset: 0; justify-content: center; - padding: 14px; + padding: 72px 14px 14px; position: fixed; z-index: 1000; } @@ -197,18 +209,36 @@ background: var(--white); border-radius: 24px 24px 18px 18px; box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28); - max-height: calc(100vh - 28px); - overflow: auto; + display: flex; + flex-direction: column; + max-height: calc(100dvh - 86px); + min-height: 0; + overflow: hidden; padding: 18px; width: 100%; } +.invoiceModalBody { + min-height: 0; + overflow-y: auto; + padding-right: 2px; + scrollbar-width: none; +} + +.invoiceModalBody::-webkit-scrollbar { + display: none; +} + .invoiceModalHeader { align-items: flex-start; + background: var(--white); display: flex; + flex-shrink: 0; gap: 12px; justify-content: space-between; margin-bottom: 16px; + position: relative; + z-index: 3; } .invoiceModalHeader h2 { @@ -256,6 +286,44 @@ font-size: 22px; } +.invoiceSteps { + background: var(--white); + display: flex; + gap: 8px; + margin-bottom: 14px; + overflow-x: auto; + padding-bottom: 8px; + position: sticky; + top: 0; + z-index: 2; + scrollbar-width: none; +} + +.invoiceSteps::-webkit-scrollbar { + display: none; +} + +.invoiceSteps span { + background: #f1f5f9; + border-radius: 999px; + color: var(--gray-dark); + flex: 0 0 auto; + font-size: 12px; + font-weight: 700; + padding: 7px 10px; +} + +.invoiceSteps .invoiceStepActive { + background: rgba(22, 128, 63, 0.14); + color: #166534; +} + +.invoiceGrid { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + .invoiceField { color: var(--black-light); display: flex; @@ -266,6 +334,10 @@ margin-bottom: 14px; } +.invoiceFieldFull { + composes: invoiceField; +} + .invoiceField input, .invoiceField select, .invoiceField textarea { @@ -284,11 +356,49 @@ resize: vertical; } -.invoiceModalActions { +.invoiceToggle { + align-items: center; + color: var(--black-light); display: flex; - flex-direction: column-reverse; + font-size: 13px; + font-weight: 700; + gap: 10px; + margin-bottom: 4px; +} + +.invoiceToggle input { + height: 18px; + width: 18px; +} + +.invoiceSummaryBox { + background: #f8fafc; + border: 1px solid var(--gray-light); + border-radius: 18px; + color: var(--black-light); + display: grid; + gap: 8px; + padding: 14px; +} + +.invoiceSummaryBox p { + font-size: 13px; + line-height: 1.35; + margin: 0; +} + +.invoiceModalActions { + background: var(--white); + display: flex; + flex-direction: column; + flex-shrink: 0; gap: 10px; margin-top: 18px; + padding-top: 12px; +} + +.invoiceModalActions .invoiceConfirmButton { + order: -1; } .invoiceCancelButton, @@ -352,13 +462,25 @@ .invoiceModal { border-radius: 24px; - max-width: 520px; + max-width: 680px; + } + + .invoiceGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .invoiceFieldFull { + grid-column: 1 / -1; } .invoiceModalActions { flex-direction: row; justify-content: flex-end; } + + .invoiceModalActions .invoiceConfirmButton { + order: initial; + } } @media (min-width: 1200px) { diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx index cd3cb41..fda58ac 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/movements/page.tsx @@ -19,7 +19,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults, } from "@core/Models/CashFlow.model"; -import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, Invoice } from "../Cash.Service"; +import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service"; import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan"; import Filterable from "@core/app/components/Filterable/Filterable"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; @@ -189,32 +189,189 @@ const getMovementId = (movement: ICashFlow) => movement.id || movement._id || "" const todayInputValue = () => dayjs().format("YYYY-MM-DD"); -const invoicePaymentMethodOptions = [ - { id: CLIENT_ACCOUNT_PAYMENT_METHODS.CASH, name: "Efectivo" }, - { id: CLIENT_ACCOUNT_PAYMENT_METHODS.BANK_TRANSFER, name: "Transferencia bancaria" }, - { id: CLIENT_ACCOUNT_PAYMENT_METHODS.CREDIT_CARD, name: "Otros medios de pago electrónicos" }, - { id: CLIENT_ACCOUNT_PAYMENT_METHODS.DEBIT_CARD, name: "Tarjeta de débito" }, - { id: CLIENT_ACCOUNT_PAYMENT_METHODS.OTHER, name: "Otros" }, +const saleConditionOptions = [ + { id: "cash", name: "Contado" }, + { id: "debit_card", name: "Tarjeta de débito" }, + { id: "credit_card", name: "Tarjeta de crédito" }, + { id: "current_account", name: "Cuenta corriente" }, + { id: "check", name: "Cheque" }, + { id: "bank_transfer", name: "Transferencia bancaria" }, + { id: "other", name: "Otra" }, + { id: "electronic_payment", name: "Otros medios de pago electrónicos" }, ]; -const isInvoicePaymentMethod = (paymentMethod?: string) => { - return invoicePaymentMethodOptions.some((option) => option.id === paymentMethod); +const receiverDocumentTypeOptions = [ + { id: "96", name: "DNI" }, + { id: "99", name: "Consumidor Final / sin documento" }, +]; + +const isSaleCondition = (saleCondition?: string) => { + return saleConditionOptions.some((option) => option.id === saleCondition); }; -const getInvoicePaymentMethod = (paymentMethod?: string) => { - if (paymentMethod && isInvoicePaymentMethod(paymentMethod)) { +const getInvoiceSaleCondition = (paymentMethod?: string) => { + if (paymentMethod && isSaleCondition(paymentMethod)) { return paymentMethod; } - return CLIENT_ACCOUNT_PAYMENT_METHODS.CASH; + return "cash"; }; +const getInvoiceServiceDescription = (movement: ICashFlow) => { + const description = (movement.description || "").trim(); + + if (!description) { + return "Servicio facturado"; + } + + const serviceMatch = description.match(/^[^-]+ - (.+?)(?: \([^)]+\))?$/); + + return serviceMatch?.[1]?.trim() || description; +}; + +const formatInvoiceDate = (value?: string | Date) => value ? dayjs(value).format("DD/MM/YYYY") : "-"; + +const formatInvoiceNumber = (value?: number, length = 5) => String(value || 0).padStart(length, "0"); + +const formatInvoiceAmount = (value?: number) => new Intl.NumberFormat("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); + +const getInvoiceSaleConditionLabel = (saleCondition?: string) => saleConditionOptions.find((option) => option.id === saleCondition)?.name || "Contado"; + +const getInvoiceTaxConditionLabel = (taxCondition?: string) => taxCondition === "EXENTO" ? "IVA Exento" : "Responsable Monotributo"; + +const buildInvoiceCopyPage = (copyLabel: string, invoice: Invoice, fiscalProfile: OrganizationFiscalProfile) => { + const legalName = fiscalProfile.legalName || "-"; + const cuit = fiscalProfile.cuit || "-"; + const pointOfSale = formatInvoiceNumber(invoice.pointOfSale || fiscalProfile.pointOfSale, 5); + const voucherNumber = formatInvoiceNumber(invoice.voucherNumber, 8); + const total = formatInvoiceAmount(invoice.itemSubtotal || invoice.amount); + const taxCondition = getInvoiceTaxConditionLabel(fiscalProfile.taxCondition); + const itemDescription = invoice.itemDescription || "Servicio facturado"; + const itemUnit = invoice.itemUnit || "unidades"; + + return [ + { + table: { widths: ["*"], body: [[{ text: copyLabel, style: "copyTitle" }]] }, + margin: [0, 0, 0, 0], + }, + { + table: { + widths: ["*", 54, "*"], + body: [[ + { stack: [ + { text: legalName, style: "issuerName" }, + { text: [{ text: "Razón Social: ", bold: true }, legalName], margin: [0, 24, 0, 0] }, + { text: [{ text: "Domicilio Comercial: ", bold: true }, "-"], margin: [0, 10, 0, 0] }, + { text: [{ text: "Condición frente al IVA: ", bold: true }, taxCondition], margin: [0, 10, 0, 0] }, + ], border: [true, true, false, true], margin: [6, 8, 6, 6] }, + { stack: [{ text: "C", style: "voucherLetter" }, { text: "COD. 011", style: "voucherCode" }], alignment: "center", border: [true, true, true, true], margin: [0, 6, 0, 4] }, + { stack: [ + { text: "FACTURA", style: "invoiceTitle" }, + { text: [{ text: "Punto de Venta: ", bold: true }, pointOfSale, { text: " Comp. Nro: ", bold: true }, voucherNumber], margin: [0, 8, 0, 0] }, + { text: [{ text: "Fecha de Emisión: ", bold: true }, formatInvoiceDate(invoice.billingDate)], margin: [0, 7, 0, 0] }, + { text: [{ text: "CUIT: ", bold: true }, cuit], margin: [0, 10, 0, 0] }, + { text: [{ text: "Ingresos Brutos: ", bold: true }, cuit], margin: [0, 2, 0, 0] }, + { text: [{ text: "Fecha de Inicio de Actividades: ", bold: true }, "-"], margin: [0, 2, 0, 0] }, + ], border: [false, true, true, true], margin: [18, 8, 6, 6] }, + ]], + }, + layout: "noPadding", + }, + { + table: { widths: ["33%", "28%", "39%"], body: [[ + { text: [{ text: "Período Facturado Desde: ", bold: true }, formatInvoiceDate(invoice.servicePeriodFrom)] }, + { text: [{ text: "Hasta: ", bold: true }, formatInvoiceDate(invoice.servicePeriodTo)] }, + { text: [{ text: "Fecha de Vto. para el pago: ", bold: true }, formatInvoiceDate(invoice.paymentDueDate)] }, + ]] }, + style: "smallTable", + }, + { + table: { widths: ["34%", "33%", "33%"], body: [ + [{ text: "Doc.: -", bold: true }, { text: "Apellido y Nombre / Razón Social:", bold: true }, { text: "" }], + [{ text: [{ text: "Condición frente al IVA: ", bold: true }, "Consumidor Final"] }, { text: "Domicilio:", bold: true }, { text: invoice.receiverAddress || "" }], + [{ text: [{ text: "Condición de venta: ", bold: true }, getInvoiceSaleConditionLabel(invoice.saleCondition)], colSpan: 3 }, {}, {}], + ] }, + margin: [0, 0, 0, 2], + }, + { + table: { + headerRows: 1, + widths: [42, "*", 58, 52, 78, 45, 70, 78], + body: [ + ["Código", "Producto / Servicio", "Cantidad", "U. Medida", "Precio Unit.", "% Bonif", "Imp. Bonif.", "Subtotal"].map((text) => ({ text, bold: true, fillColor: "#d7d7d7", alignment: "center" })), + ["", itemDescription, formatInvoiceAmount(invoice.itemQuantity || 1), itemUnit, formatInvoiceAmount(invoice.itemUnitPrice || invoice.amount), formatInvoiceAmount(invoice.itemDiscountPercent), formatInvoiceAmount(invoice.itemDiscountAmount), total], + ], + }, + fontSize: 7, + }, + { text: " ", margin: [0, 154, 0, 0] }, + { + table: { widths: ["*"], body: [[{ stack: [ + { text: [{ text: "Subtotal: $ ", bold: true }, total], alignment: "right", margin: [0, 8, 0, 0] }, + { text: [{ text: "Importe Otros Tributos: $ ", bold: true }, "0,00"], alignment: "right", margin: [0, 8, 0, 0] }, + { text: [{ text: "Importe Total: $ ", bold: true }, total], alignment: "right", margin: [0, 8, 0, 8] }, + ] }]] }, + }, + { + columns: [ + { columns: [ + { qr: buildArcaQrUrl(invoice, fiscalProfile), fit: 66, width: 78, margin: [0, 13, 0, 0] }, + { stack: [{ text: "ARCA", style: "arcaLogo" }, { text: "AGENCIA DE RECAUDACIÓN\nY CONTROL ADUANERO", fontSize: 5, color: "#6b7280", margin: [0, -3, 0, 0] }, { text: "Comprobante Autorizado", bold: true, italics: true, margin: [0, 11, 0, 0] }, { text: "Esta Agencia no se responsabiliza por los datos ingresados en el detalle de la operación", fontSize: 6, italics: true, margin: [0, 8, 0, 0] }], width: "*" }, + ], width: "40%" }, + { text: "Pág. 1/1", bold: true, alignment: "center", width: "20%", margin: [0, 34, 0, 0] }, + { stack: [{ text: [{ text: "CAE N°: ", bold: true }, invoice.cae || "-"] }, { text: [{ text: "Fecha de Vto. de CAE: ", bold: true }, formatInvoiceDate(invoice.caeExpiresAt)], margin: [0, 8, 0, 0] }], width: "40%", alignment: "right", margin: [0, 30, 0, 0] }, + ], + margin: [0, 30, 0, 0], + pageBreak: copyLabel === "TRIPLICADO" ? undefined : "after", + }, + ]; +}; + +const buildArcaQrUrl = (invoice: Invoice, fiscalProfile: OrganizationFiscalProfile) => { + const payload = { + ver: 1, + fecha: dayjs(invoice.billingDate).format("YYYY-MM-DD"), + cuit: Number(fiscalProfile.cuit || 0), + ptoVta: invoice.pointOfSale || fiscalProfile.pointOfSale || 0, + tipoCmp: 11, + nroCmp: invoice.voucherNumber || 0, + importe: Number(invoice.itemSubtotal || invoice.amount || 0), + moneda: "PES", + ctz: 1, + tipoDocRec: Number(invoice.receiverDocumentType || 99), + nroDocRec: Number(invoice.receiverDocumentNumber || 0), + tipoCodAut: "E", + codAut: Number(invoice.cae || 0), + }; + + return `https://www.afip.gob.ar/fe/qr/?p=${btoa(JSON.stringify(payload))}`; +}; + +const invoiceWizardSteps = ["Datos de emisión", "Datos del receptor", "Datos de la operación", "Resumen"]; + type InvoiceDraft = { movement: ICashFlow; movementId: string; billingDate: string; - paymentMethod: string; - description: string; + servicePeriodFrom: string; + servicePeriodTo: string; + paymentDueDate: string; + activityCode: string; + activityDescription: string; + saleCondition: string; + includeReceiverData: boolean; + receiverDocumentType: string; + receiverDocumentNumber: string; + receiverName: string; + receiverAddress: string; + receiverEmail: string; + itemDescription: string; + itemQuantity: number; + itemUnit: string; + itemUnitPrice: number; + itemDiscountPercent: number; + itemDiscountAmount: number; + itemSubtotal: number; }; const getPaymentMethodCardVisual = (paymentMethod?: PAYMENT_METHODS | string) => { @@ -304,6 +461,8 @@ export default function CashFlowMovementsList() { const [invoicingMovementId, setInvoicingMovementId] = useState(""); const [invoiceDraft, setInvoiceDraft] = useState(null); const [invoiceDraftsByMovementId, setInvoiceDraftsByMovementId] = useState>({}); + const [fiscalProfile, setFiscalProfile] = useState({ activityCode: "", activityDescription: "" }); + const [invoiceStep, setInvoiceStep] = useState(0); const movementsRef = useRef(movements); const loadMovements = (page: number = 1, pageSize: number = 30) => { @@ -428,6 +587,19 @@ export default function CashFlowMovementsList() { loadMovements(loadPage, pageSize); }, [clientId, collaboratorId, methodId]); + useEffect(() => { + if (!SessionInfo.userId) return; + + findOrganizationFiscalProfiles({ companyId: id, sessionUser: SessionInfo.userId }) + .then((profiles) => { + const profile = profiles[0]; + setFiscalProfile(profile || { activityCode: "", activityDescription: "" }); + }) + .catch(() => { + setFiscalProfile({ activityCode: "", activityDescription: "" }); + }); + }, [SessionInfo.userId]); + const renderMovementMeta = (movement: ICashFlow) => { const details = [ movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null, @@ -463,12 +635,32 @@ export default function CashFlowMovementsList() { const savedDraft = invoiceDraftsByMovementId[movementId]; + const defaultDate = todayInputValue(); + + setInvoiceStep(0); setInvoiceDraft(savedDraft ? { ...savedDraft, movement } : { movement, movementId, - billingDate: dayjs(movement.createdAt).format("YYYY-MM-DD"), - paymentMethod: getInvoicePaymentMethod(movement.paymentMethod), - description: movement.description || "", + billingDate: defaultDate, + servicePeriodFrom: defaultDate, + servicePeriodTo: defaultDate, + paymentDueDate: defaultDate, + activityCode: fiscalProfile.activityCode || "", + activityDescription: fiscalProfile.activityDescription || "", + saleCondition: getInvoiceSaleCondition(movement.paymentMethod), + includeReceiverData: false, + receiverDocumentType: "", + receiverDocumentNumber: "", + receiverName: "", + receiverAddress: "", + receiverEmail: "", + itemDescription: getInvoiceServiceDescription(movement), + itemQuantity: 1, + itemUnit: "", + itemUnitPrice: movement.amount, + itemDiscountPercent: 0, + itemDiscountAmount: 0, + itemSubtotal: movement.amount, }); }; @@ -483,6 +675,65 @@ export default function CashFlowMovementsList() { return remaining; }); setInvoiceDraft(null); + setInvoiceStep(0); + } + }; + + const validateInvoiceStep = (step: number) => { + if (!invoiceDraft) return false; + + if (step === 0) { + if (!invoiceDraft.billingDate || !invoiceDraft.servicePeriodFrom || !invoiceDraft.servicePeriodTo || !invoiceDraft.paymentDueDate) { + alert.showError("Debe completar las fechas de emisión, período y vencimiento."); + return false; + } + + if (invoiceDraft.servicePeriodFrom > invoiceDraft.servicePeriodTo) { + alert.showError("El inicio del período facturado no puede ser posterior al fin."); + return false; + } + + if (!invoiceDraft.activityCode.trim() || !invoiceDraft.activityDescription.trim()) { + alert.showError("Debe completar el código y la descripción de actividad."); + return false; + } + } + + if (step === 1 && !invoiceDraft.saleCondition) { + alert.showError("Debe seleccionar la condición de venta."); + return false; + } + + if (step === 1 && invoiceDraft.includeReceiverData) { + if (!invoiceDraft.receiverDocumentType) { + alert.showError("Debe seleccionar el tipo de documento del receptor."); + return false; + } + + if (invoiceDraft.receiverDocumentType === "99" && invoiceDraft.receiverDocumentNumber.trim() !== "0") { + alert.showError("El número de documento debe ser 0 para Consumidor Final sin documento."); + return false; + } + } + + if (step === 2) { + if (!invoiceDraft.itemDescription.trim()) { + alert.showError("Debe ingresar la descripción del servicio facturado."); + return false; + } + + if (Math.abs(invoiceDraft.itemSubtotal - invoiceDraft.movement.amount) > 0.01) { + alert.showError("El total del ítem debe coincidir con el importe del movimiento."); + return false; + } + } + + return true; + }; + + const goToNextInvoiceStep = () => { + if (validateInvoiceStep(invoiceStep)) { + setInvoiceStep((current) => Math.min(current + 1, invoiceWizardSteps.length - 1)); } }; @@ -496,13 +747,7 @@ export default function CashFlowMovementsList() { return; } - if (!invoiceDraft.paymentMethod) { - alert.showError("Debe seleccionar el medio de pago."); - return; - } - - if (!invoiceDraft.description.trim()) { - alert.showError("Debe ingresar el detalle de la factura."); + if (!invoiceWizardSteps.every((_, index) => validateInvoiceStep(index))) { return; } @@ -512,8 +757,25 @@ export default function CashFlowMovementsList() { createInvoiceFromCashMovement({ cashMovementId: invoiceDraft.movementId, billingDate: invoiceDraft.billingDate, - paymentMethod: invoiceDraft.paymentMethod, - description: invoiceDraft.description.trim(), + servicePeriodFrom: invoiceDraft.servicePeriodFrom, + servicePeriodTo: invoiceDraft.servicePeriodTo, + paymentDueDate: invoiceDraft.paymentDueDate, + activityCode: invoiceDraft.activityCode.trim(), + activityDescription: invoiceDraft.activityDescription.trim(), + receiverIvaCondition: 5, + receiverDocumentType: invoiceDraft.includeReceiverData ? invoiceDraft.receiverDocumentType : undefined, + receiverDocumentNumber: invoiceDraft.includeReceiverData ? invoiceDraft.receiverDocumentNumber.trim() : undefined, + receiverName: invoiceDraft.includeReceiverData ? invoiceDraft.receiverName.trim() : undefined, + receiverAddress: invoiceDraft.includeReceiverData ? invoiceDraft.receiverAddress.trim() : undefined, + receiverEmail: invoiceDraft.includeReceiverData ? invoiceDraft.receiverEmail.trim() : undefined, + saleCondition: invoiceDraft.saleCondition, + itemDescription: invoiceDraft.itemDescription.trim(), + itemQuantity: invoiceDraft.itemQuantity, + itemUnit: invoiceDraft.itemUnit.trim(), + itemUnitPrice: invoiceDraft.itemUnitPrice, + itemDiscountPercent: invoiceDraft.itemDiscountPercent, + itemDiscountAmount: invoiceDraft.itemDiscountAmount, + itemSubtotal: invoiceDraft.itemSubtotal, sessionUser: SessionInfo.userId, }) .then((invoice) => { @@ -550,10 +812,45 @@ export default function CashFlowMovementsList() { .finally(() => { setInvoicingMovementId(""); setInvoiceDraft(null); + setInvoiceStep(0); eventHandler.setEventType(EVENT_TYPES.CLEAR); }); }; + const handleDownloadInvoicePdf = async (invoice: Invoice) => { + if (invoice.status !== "APPROVED") { + alert.showError("Solo se puede descargar el PDF de facturas aprobadas."); + return; + } + + try { + const pdfMake = await import("pdfmake/build/pdfmake"); + const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any; + pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; + + const content = ["ORIGINAL", "DUPLICADO", "TRIPLICADO"].flatMap((copyLabel) => buildInvoiceCopyPage(copyLabel, invoice, fiscalProfile)); + const documentDefinition: any = { + pageSize: "A4", + pageMargins: [24, 14, 24, 18], + defaultStyle: { fontSize: 8, color: "#111111" }, + styles: { + copyTitle: { fontSize: 15, bold: true, alignment: "center", margin: [0, 4, 0, 4] }, + issuerName: { bold: true, alignment: "center", margin: [0, 14, 0, 0] }, + voucherLetter: { fontSize: 27, bold: true }, + voucherCode: { fontSize: 8, bold: true }, + invoiceTitle: { fontSize: 22, bold: true, margin: [0, 6, 0, 0] }, + smallTable: { margin: [0, 0, 0, 0] }, + arcaLogo: { fontSize: 20, bold: true, color: "#4b5563", margin: [0, 13, 0, 0] }, + }, + }; + + (pdfMake as any).createPdf({ ...documentDefinition, content }).download(`factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`); + } catch (error) { + console.error("Error generating invoice PDF:", error); + alert.showError("No pudimos generar el PDF de la factura."); + } + }; + return ( <> @@ -612,9 +909,16 @@ export default function CashFlowMovementsList() {
{isInvoiced ? ( - - Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber} - + <> + + Factura C {invoice?.pointOfSale}-{invoice?.voucherNumber} + + {invoice && ( + + )} + ) : isPendingInvoice ? ( Factura pendiente @@ -671,58 +975,113 @@ export default function CashFlowMovementsList() {
-
- Importe - {formatPrice(invoiceDraft.movement.amount)} -
- - - - + -