feat: implement organization fiscal profile management including ARCA activity search and fiscal data configuration

This commit is contained in:
2026-08-10 13:05:09 -03:00
parent ebd8096112
commit 6e7bf6e835
23 changed files with 1876 additions and 187 deletions
@@ -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,
}));
};
@@ -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;
};
@@ -177,6 +177,29 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
};
}
private escapeXml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 `<?xml version="1.0" encoding="UTF-8"?>\n<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wsaa="http://wsaa.view.sua.dvadac.desein.afip.gov">\n<soapenv:Header/>\n<soapenv:Body>\n<wsaa:loginCms>\n<wsaa:in0>${cms}</wsaa:in0>\n</wsaa:loginCms>\n</soapenv:Body>\n</soapenv:Envelope>`;
return `<?xml version="1.0" encoding="UTF-8"?>\n<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wsaa="http://wsaa.view.sua.dvadac.desein.afip.gov">\n<soapenv:Header/>\n<soapenv:Body>\n<wsaa:loginCms>\n<wsaa:in0>${this.escapeXml(cms)}</wsaa:in0>\n</wsaa:loginCms>\n</soapenv:Body>\n</soapenv:Envelope>`;
}
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 `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<FECompUltimoAutorizado xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
<Token>${this.escapeXml(data.token)}</Token>
<Sign>${this.escapeXml(data.sign)}</Sign>
<Cuit>${this.escapeXml(data.cuit)}</Cuit>
</Auth>
<PtoVta>${data.pointOfSale}</PtoVta>
<CbteTipo>${data.voucherType}</CbteTipo>
<PtoVta>${pointOfSale}</PtoVta>
<CbteTipo>${voucherType}</CbteTipo>
</FECompUltimoAutorizado>
</soapenv:Body>
</soapenv:Envelope>`;
@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
@@ -369,23 +404,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
<soapenv:Body>
<FECAESolicitar xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
<Token>${this.escapeXml(data.token)}</Token>
<Sign>${this.escapeXml(data.sign)}</Sign>
<Cuit>${this.escapeXml(data.cuit)}</Cuit>
</Auth>
<FeCAEReq>
<FeCabReq>
<CantReg>1</CantReg>
<PtoVta>${data.pointOfSale}</PtoVta>
<PtoVta>${pointOfSale}</PtoVta>
<CbteTipo>11</CbteTipo>
</FeCabReq>
<FeDetReq>
<FECAEDetRequest>
<Concepto>1</Concepto>
<DocTipo>${FINAL_CONSUMER_DOCUMENT_TYPE}</DocTipo>
<DocNro>${FINAL_CONSUMER_DOCUMENT_NUMBER}</DocNro>
<CbteDesde>${data.voucherNumber}</CbteDesde>
<CbteHasta>${data.voucherNumber}</CbteHasta>
<Concepto>3</Concepto>
<DocTipo>${receiverDocumentType}</DocTipo>
<DocNro>${receiverDocumentNumber}</DocNro>
<CbteDesde>${voucherNumber}</CbteDesde>
<CbteHasta>${voucherNumber}</CbteHasta>
<CbteFch>${data.voucherDate}</CbteFch>
<ImpTotal>${amount}</ImpTotal>
<ImpTotConc>0.00</ImpTotConc>
@@ -393,6 +428,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
<ImpOpEx>0.00</ImpOpEx>
<ImpTrib>0.00</ImpTrib>
<ImpIVA>0.00</ImpIVA>
<FchServDesde>${data.servicePeriodFrom}</FchServDesde>
<FchServHasta>${data.servicePeriodTo}</FchServHasta>
<FchVtoPago>${data.paymentDueDate}</FchVtoPago>
<MonId>PES</MonId>
<MonCotiz>1</MonCotiz>
<CondicionIVAReceptorId>${FINAL_CONSUMER_IVA_CONDITION}</CondicionIVAReceptorId>
@@ -421,9 +459,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
<soapenv:Body>
<FEParamGetTiposCbte xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
<Token>${this.escapeXml(data.token)}</Token>
<Sign>${this.escapeXml(data.sign)}</Sign>
<Cuit>${this.escapeXml(data.cuit)}</Cuit>
</Auth>
</FEParamGetTiposCbte>
</soapenv:Body>
@@ -437,9 +475,9 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
<soapenv:Body>
<FEParamGetPtosVenta xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
<Token>${this.escapeXml(data.token)}</Token>
<Sign>${this.escapeXml(data.sign)}</Sign>
<Cuit>${this.escapeXml(data.cuit)}</Cuit>
</Auth>
</FEParamGetPtosVenta>
</soapenv:Body>
@@ -447,10 +485,10 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
}
private async postWsfe(soapBody: string, soapAction: string): Promise<string> {
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"
),
@@ -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 },
@@ -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;
+212 -30
View File
@@ -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<IInvoice> {
@@ -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,
});
@@ -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,
@@ -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;
@@ -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(),
});
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async allArcaActivities(
@Body() requestBody: FilterArcaActivitiesParams
): Promise<TextObjectFilterResult[] | ApiValidationError> {
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);
}
}
}
@@ -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<TextObjectFilterResult[]> {
return filterArcaActivities(data.name);
}
}