feat: implement ARCA integration architecture including credential, fiscal profile, and invoice models, services, and onboarding flows
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
ARCA_CREDENTIAL_STATUS,
|
||||
CreateArcaCredentialParams,
|
||||
FindArcaCredentialsParams,
|
||||
IArcaCredential,
|
||||
IArcaCredentialsAdapter,
|
||||
UpdateArcaCredentialParams,
|
||||
} from "./ArcaCredentials.Interface";
|
||||
|
||||
export interface IArcaCredentialDocument extends Omit<IArcaCredential, "id">, Document {}
|
||||
|
||||
export class ArcaCredentialsAdapterMongoose implements IArcaCredentialsAdapter {
|
||||
schema: Schema;
|
||||
arcaCredentialList: Model<IArcaCredentialDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
companyId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "Company",
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
csrPem: { type: String, required: false },
|
||||
certificatePem: { type: String, required: false },
|
||||
encryptedPrivateKey: { type: String, required: true },
|
||||
privateKeyIv: { type: String, required: true },
|
||||
privateKeyAuthTag: { type: String, required: true },
|
||||
encryptionKeyVersion: { type: String, required: false, default: "v1" },
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: Object.values(ARCA_CREDENTIAL_STATUS),
|
||||
index: true,
|
||||
},
|
||||
certificateValidFrom: { type: Date, required: false },
|
||||
certificateValidTo: { type: Date, required: false },
|
||||
lastValidationError: { type: String, required: false },
|
||||
createdAt: { type: Date, required: true, default: Date.now },
|
||||
updatedAt: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.arcaCredentialList = model<IArcaCredentialDocument>("ArcaCredential", this.schema);
|
||||
}
|
||||
|
||||
public async create(data: CreateArcaCredentialParams): Promise<IArcaCredential> {
|
||||
return await this.arcaCredentialList.create(data);
|
||||
}
|
||||
|
||||
private buildSearchCriteria(filters: FindArcaCredentialsParams): FilterQuery<IArcaCredential> {
|
||||
const searchCriteria: FilterQuery<IArcaCredential> = {};
|
||||
|
||||
if (filters.id) searchCriteria._id = filters.id;
|
||||
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||
if (filters.status) searchCriteria.status = filters.status;
|
||||
|
||||
return searchCriteria;
|
||||
}
|
||||
|
||||
public async find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.arcaCredentialList.find(searchCriteria).sort({ createdAt: -1 }).exec();
|
||||
}
|
||||
|
||||
public async findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.arcaCredentialList.findOne(searchCriteria).exec();
|
||||
}
|
||||
|
||||
public async update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null> {
|
||||
const { id, companyId, sessionUser, ...updateData } = data;
|
||||
|
||||
if (!id && !companyId) {
|
||||
throw new Error("Se requiere un identificador para actualizar las credenciales ARCA");
|
||||
}
|
||||
|
||||
return this.arcaCredentialList
|
||||
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, { new: true })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { IArcaCredentialDocument } from "./ArcaCredentials.Adapter.Mongoose";
|
||||
|
||||
export enum ARCA_CREDENTIAL_STATUS {
|
||||
CSR_GENERATED = "CSR_GENERATED",
|
||||
CERTIFICATE_UPLOADED = "CERTIFICATE_UPLOADED",
|
||||
READY = "READY",
|
||||
INVALID = "INVALID",
|
||||
DISABLED = "DISABLED",
|
||||
}
|
||||
|
||||
export type GenerateArcaCredentialCsrParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type UploadArcaCredentialCertificateParams = {
|
||||
companyId: string;
|
||||
certificatePem: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type TestArcaWsaaLoginParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ArcaWsaaLoginTestResult = {
|
||||
status: "cached" | "created";
|
||||
environment: "homologation";
|
||||
service: "wsfe";
|
||||
expirationTime: Date;
|
||||
cached: boolean;
|
||||
};
|
||||
|
||||
export type ArcaWsfeLastVoucherParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ArcaWsfeLastVoucherResult = {
|
||||
environment: "homologation";
|
||||
service: "wsfe";
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
lastVoucherNumber: number;
|
||||
};
|
||||
|
||||
export type ArcaWsfeDiagnosticsParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ArcaWsfeDiagnosticsResult = {
|
||||
environment: "homologation";
|
||||
service: "wsfe";
|
||||
dummy: {
|
||||
ok: boolean;
|
||||
appServer?: string;
|
||||
dbServer?: string;
|
||||
authServer?: string;
|
||||
error?: string;
|
||||
};
|
||||
voucherTypes: {
|
||||
ok: boolean;
|
||||
count?: number;
|
||||
includesFacturaC?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
pointsOfSale: {
|
||||
ok: boolean;
|
||||
points?: number[];
|
||||
includesConfiguredPointOfSale?: boolean;
|
||||
configuredPointOfSale: number;
|
||||
error?: string;
|
||||
};
|
||||
lastVoucher: {
|
||||
ok: boolean;
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
lastVoucherNumber?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FindArcaCredentialsParams = {
|
||||
id?: string;
|
||||
companyId?: string;
|
||||
status?: ARCA_CREDENTIAL_STATUS;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type CreateArcaCredentialParams = {
|
||||
companyId: string;
|
||||
csrPem?: string;
|
||||
certificatePem?: string;
|
||||
encryptedPrivateKey: string;
|
||||
privateKeyIv: string;
|
||||
privateKeyAuthTag: string;
|
||||
encryptionKeyVersion?: string;
|
||||
status: ARCA_CREDENTIAL_STATUS;
|
||||
certificateValidFrom?: Date;
|
||||
certificateValidTo?: Date;
|
||||
lastValidationError?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UpdateArcaCredentialParams = FindArcaCredentialsParams & {
|
||||
csrPem?: string;
|
||||
certificatePem?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
privateKeyIv?: string;
|
||||
privateKeyAuthTag?: string;
|
||||
encryptionKeyVersion?: string;
|
||||
status?: ARCA_CREDENTIAL_STATUS;
|
||||
certificateValidFrom?: Date;
|
||||
certificateValidTo?: Date;
|
||||
lastValidationError?: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface IArcaCredential {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
csrPem?: string;
|
||||
certificatePem?: string;
|
||||
encryptedPrivateKey: string;
|
||||
privateKeyIv: string;
|
||||
privateKeyAuthTag: string;
|
||||
encryptionKeyVersion?: string;
|
||||
status: ARCA_CREDENTIAL_STATUS;
|
||||
certificateValidFrom?: Date;
|
||||
certificateValidTo?: Date;
|
||||
lastValidationError?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type ArcaCredentialView = Omit<
|
||||
IArcaCredential,
|
||||
"encryptedPrivateKey" | "privateKeyIv" | "privateKeyAuthTag"
|
||||
>;
|
||||
|
||||
export interface IArcaCredentialsAdapter {
|
||||
create(data: CreateArcaCredentialParams): Promise<IArcaCredential>;
|
||||
find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]>;
|
||||
findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null>;
|
||||
update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null>;
|
||||
}
|
||||
|
||||
export interface IArcaCredentialsManager {
|
||||
arcaCredentials: IArcaCredentialsAdapter;
|
||||
generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView>;
|
||||
uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>;
|
||||
testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>;
|
||||
getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>;
|
||||
getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>;
|
||||
find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>;
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import forge from "node-forge";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
|
||||
import {
|
||||
ARCA_WSAA_ENVIRONMENT,
|
||||
ARCA_WSAA_SERVICE,
|
||||
} from "../ArcaWsaaTokens/ArcaWsaaTokens.Interface";
|
||||
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
|
||||
import {
|
||||
IOrganizationFiscalProfile,
|
||||
ORGANIZATION_FISCAL_PROFILE_STATUS,
|
||||
} from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
|
||||
import { ArcaCredentialsAdapterMongoose } from "./ArcaCredentials.Adapter.Mongoose";
|
||||
import {
|
||||
ARCA_CREDENTIAL_STATUS,
|
||||
ArcaCredentialView,
|
||||
ArcaWsfeDiagnosticsParams,
|
||||
ArcaWsfeDiagnosticsResult,
|
||||
ArcaWsfeLastVoucherParams,
|
||||
ArcaWsfeLastVoucherResult,
|
||||
FindArcaCredentialsParams,
|
||||
GenerateArcaCredentialCsrParams,
|
||||
ArcaWsaaLoginTestResult,
|
||||
IArcaCredential,
|
||||
IArcaCredentialsManager,
|
||||
TestArcaWsaaLoginParams,
|
||||
UploadArcaCredentialCertificateParams,
|
||||
} from "./ArcaCredentials.Interface";
|
||||
|
||||
class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
arcaCredentials: ArcaCredentialsAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.arcaCredentials = new ArcaCredentialsAdapterMongoose();
|
||||
}
|
||||
|
||||
private getMasterKey(): Buffer {
|
||||
const configuredKey = process.env.ARCA_CREDENTIALS_MASTER_KEY;
|
||||
|
||||
if (!configuredKey) {
|
||||
throw new Error("ARCA_CREDENTIALS_MASTER_KEY no está configurada");
|
||||
}
|
||||
|
||||
const key = /^[a-fA-F0-9]{64}$/.test(configuredKey)
|
||||
? Buffer.from(configuredKey, "hex")
|
||||
: Buffer.from(configuredKey, "base64");
|
||||
|
||||
if (key.length !== 32) {
|
||||
throw new Error("ARCA_CREDENTIALS_MASTER_KEY debe tener 32 bytes en base64 o 64 caracteres hexadecimales");
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
private encryptPrivateKey(privateKeyPem: string): {
|
||||
encryptedPrivateKey: string;
|
||||
privateKeyIv: string;
|
||||
privateKeyAuthTag: string;
|
||||
} {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(
|
||||
"aes-256-gcm",
|
||||
crypto.createSecretKey(new Uint8Array(this.getMasterKey())),
|
||||
new Uint8Array(iv)
|
||||
);
|
||||
const encryptedPrivateKey = cipher.update(privateKeyPem, "utf8", "base64") + cipher.final("base64");
|
||||
|
||||
return {
|
||||
encryptedPrivateKey,
|
||||
privateKeyIv: iv.toString("base64"),
|
||||
privateKeyAuthTag: cipher.getAuthTag().toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
private decryptPrivateKey(credential: IArcaCredential): string {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
crypto.createSecretKey(new Uint8Array(this.getMasterKey())),
|
||||
new Uint8Array(Buffer.from(credential.privateKeyIv, "base64"))
|
||||
);
|
||||
decipher.setAuthTag(new Uint8Array(Buffer.from(credential.privateKeyAuthTag, "base64")));
|
||||
|
||||
return decipher.update(credential.encryptedPrivateKey, "base64", "utf8") + decipher.final("utf8");
|
||||
}
|
||||
|
||||
private sanitizeCredential(credential: IArcaCredential): ArcaCredentialView {
|
||||
const rawCredential = typeof (credential as any).toObject === "function"
|
||||
? (credential as any).toObject()
|
||||
: credential;
|
||||
const { encryptedPrivateKey, privateKeyIv, privateKeyAuthTag, ...safeCredential } = rawCredential;
|
||||
return safeCredential;
|
||||
}
|
||||
|
||||
private async validateCompanyAdmin(companyId: string, sessionUser: string): Promise<void> {
|
||||
await validateSessionUser({ sessionUser });
|
||||
|
||||
if (!companyId || !companyId.trim()) {
|
||||
throw new Error("Se requiere una organización para operar credenciales ARCA");
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({ companyId, sessionUser });
|
||||
}
|
||||
|
||||
private async requireActiveFiscalProfile(
|
||||
companyId: string,
|
||||
sessionUser: string
|
||||
): Promise<IOrganizationFiscalProfile> {
|
||||
const profiles = await OrganizationFiscalProfiles.find({
|
||||
companyId,
|
||||
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||
sessionUser,
|
||||
});
|
||||
|
||||
if (!profiles.length) {
|
||||
throw new Error("Se requiere un perfil fiscal activo para generar credenciales ARCA");
|
||||
}
|
||||
|
||||
return profiles[0];
|
||||
}
|
||||
|
||||
private parseCertificate(certificatePem: string): forge.pki.Certificate {
|
||||
try {
|
||||
return forge.pki.certificateFromPem(certificatePem);
|
||||
} catch (error) {
|
||||
const errorOccurred = error as Error;
|
||||
throw new Error(`El certificado ARCA no es válido: ${errorOccurred.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private certificateMatchesPrivateKey(certificate: forge.pki.Certificate, privateKeyPem: string): boolean {
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
const publicKeyFromPrivateKey = forge.pki.setRsaPublicKey(privateKey.n, privateKey.e);
|
||||
|
||||
return forge.pki.publicKeyToPem(certificate.publicKey) === forge.pki.publicKeyToPem(publicKeyFromPrivateKey);
|
||||
}
|
||||
|
||||
private formatWsaaDate(date: Date): string {
|
||||
// WSAA accepts ISO-8601 timestamps with an explicit offset; keep the Date instant in UTC.
|
||||
return date.toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
||||
}
|
||||
|
||||
private buildTraXml(service: ARCA_WSAA_SERVICE): {
|
||||
traXml: string;
|
||||
generationTime: Date;
|
||||
expirationTime: Date;
|
||||
} {
|
||||
const generationTime = new Date(Date.now() - 10 * 60 * 1000);
|
||||
const expirationTime = new Date(Date.now() + 12 * 60 * 60 * 1000);
|
||||
const uniqueId = Math.floor(Date.now() / 1000);
|
||||
|
||||
return {
|
||||
generationTime,
|
||||
expirationTime,
|
||||
traXml: `<?xml version="1.0" encoding="UTF-8"?>\n<loginTicketRequest version="1.0">\n<header>\n<uniqueId>${uniqueId}</uniqueId>\n<generationTime>${this.formatWsaaDate(generationTime)}</generationTime>\n<expirationTime>${this.formatWsaaDate(expirationTime)}</expirationTime>\n</header>\n<service>${service}</service>\n</loginTicketRequest>`,
|
||||
};
|
||||
}
|
||||
|
||||
private signTraCms(traXml: string, certificatePem: string, privateKeyPem: string): string {
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(traXml, "utf8");
|
||||
p7.addCertificate(certificatePem);
|
||||
p7.addSigner({
|
||||
key: privateKeyPem,
|
||||
certificate: certificatePem,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
|
||||
{ type: forge.pki.oids.messageDigest },
|
||||
{ type: forge.pki.oids.signingTime, value: new Date() as any },
|
||||
],
|
||||
});
|
||||
p7.sign();
|
||||
|
||||
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
|
||||
return Buffer.from(der, "binary").toString("base64");
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
private extractXmlValue(xml: string, tagName: string): string | undefined {
|
||||
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
private decodeXmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
private parseLoginCmsReturn(xml: string): {
|
||||
token: string;
|
||||
sign: string;
|
||||
generationTime?: Date;
|
||||
expirationTime: Date;
|
||||
} {
|
||||
const loginCmsReturn = this.extractXmlValue(xml, "loginCmsReturn");
|
||||
const responseXml = loginCmsReturn ? this.decodeXmlEntities(loginCmsReturn) : xml;
|
||||
const token = this.extractXmlValue(responseXml, "token");
|
||||
const sign = this.extractXmlValue(responseXml, "sign");
|
||||
const expirationTime = this.extractXmlValue(responseXml, "expirationTime");
|
||||
const generationTime = this.extractXmlValue(responseXml, "generationTime");
|
||||
|
||||
if (!token || !sign || !expirationTime) {
|
||||
throw new Error("ARCA WSAA no devolvió token, firma o vencimiento válidos");
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
sign,
|
||||
generationTime: generationTime ? new Date(generationTime) : undefined,
|
||||
expirationTime: new Date(expirationTime),
|
||||
};
|
||||
}
|
||||
|
||||
private async getWsaaLoginTicket(companyId: string): Promise<{
|
||||
token: string;
|
||||
sign: string;
|
||||
expirationTime: Date;
|
||||
cached: boolean;
|
||||
}> {
|
||||
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
|
||||
const cachedToken = await ArcaWsaaTokens.getValidToken({ companyId, service, environment });
|
||||
|
||||
if (cachedToken) {
|
||||
return {
|
||||
token: cachedToken.token,
|
||||
sign: cachedToken.sign,
|
||||
expirationTime: cachedToken.expirationTime,
|
||||
cached: true,
|
||||
};
|
||||
}
|
||||
|
||||
const credential = await this.arcaCredentials.findOne({
|
||||
companyId,
|
||||
status: ARCA_CREDENTIAL_STATUS.READY,
|
||||
});
|
||||
|
||||
if (!credential?.certificatePem) {
|
||||
throw new Error("Se requieren credenciales ARCA READY con certificado cargado");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (!wsaaUrl) {
|
||||
throw new Error("ARCA_WSAA_HOMOLOGATION_URL no está configurada");
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await axios.post(wsaaUrl, this.buildLoginCmsSoap(cms), {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
SOAPAction: "loginCms",
|
||||
},
|
||||
transformResponse: [(body) => body],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(this.buildWsaaErrorMessage(error));
|
||||
}
|
||||
|
||||
const wsaaToken = this.parseLoginCmsReturn(String(response.data));
|
||||
const savedToken = await ArcaWsaaTokens.upsertToken({
|
||||
companyId,
|
||||
service,
|
||||
environment,
|
||||
...wsaaToken,
|
||||
});
|
||||
|
||||
return {
|
||||
token: savedToken.token,
|
||||
sign: savedToken.sign,
|
||||
expirationTime: savedToken.expirationTime,
|
||||
cached: false,
|
||||
};
|
||||
}
|
||||
|
||||
private buildWsfeLastVoucherSoap(data: {
|
||||
token: string;
|
||||
sign: string;
|
||||
cuit: string;
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
}): string {
|
||||
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>
|
||||
</Auth>
|
||||
<PtoVta>${data.pointOfSale}</PtoVta>
|
||||
<CbteTipo>${data.voucherType}</CbteTipo>
|
||||
</FECompUltimoAutorizado>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private buildWsfeDummySoap(): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<FEDummy xmlns="http://ar.gov.afip.dif.FEV1/" />
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private buildWsfeParamGetTiposCbteSoap(data: { token: string; sign: string; cuit: string }): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<FEParamGetTiposCbte xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||
<Auth>
|
||||
<Token>${data.token}</Token>
|
||||
<Sign>${data.sign}</Sign>
|
||||
<Cuit>${data.cuit}</Cuit>
|
||||
</Auth>
|
||||
</FEParamGetTiposCbte>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private buildWsfeParamGetPtosVentaSoap(data: { token: string; sign: string; cuit: string }): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<FEParamGetPtosVenta xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||
<Auth>
|
||||
<Token>${data.token}</Token>
|
||||
<Sign>${data.sign}</Sign>
|
||||
<Cuit>${data.cuit}</Cuit>
|
||||
</Auth>
|
||||
</FEParamGetPtosVenta>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>`;
|
||||
}
|
||||
|
||||
private async postWsfe(soapBody: string, soapAction: string): Promise<string> {
|
||||
const wsfeUrl = process.env.ARCA_WSFE_HOMOLOGATION_URL;
|
||||
|
||||
if (!wsfeUrl) {
|
||||
throw new Error("ARCA_WSFE_HOMOLOGATION_URL no está configurada");
|
||||
}
|
||||
|
||||
const response = await axios.post(wsfeUrl, soapBody, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
SOAPAction: `"http://ar.gov.afip.dif.FEV1/${soapAction}"`,
|
||||
},
|
||||
transformResponse: [(body) => body],
|
||||
});
|
||||
|
||||
return String(response.data);
|
||||
}
|
||||
|
||||
private buildWsfeErrorMessage(error: unknown): string {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSFE";
|
||||
}
|
||||
|
||||
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
|
||||
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
|
||||
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
|
||||
|
||||
if (decodedFault) {
|
||||
return `ARCA WSFE rechazó la consulta: ${decodedFault}`;
|
||||
}
|
||||
|
||||
const htmlTitle = responseBody ? this.extractHtmlTitle(responseBody) : undefined;
|
||||
|
||||
if (htmlTitle && error.response?.status) {
|
||||
return `ARCA WSFE respondió con estado ${error.response.status}: ${htmlTitle}`;
|
||||
}
|
||||
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return "ARCA WSFE no respondió a tiempo. Probá nuevamente en unos minutos.";
|
||||
}
|
||||
|
||||
if (error.response?.status) {
|
||||
if (responseBody) {
|
||||
const compactBody = responseBody.replace(/\s+/g, " ").trim().slice(0, 500);
|
||||
return `ARCA WSFE respondió con estado ${error.response.status}: ${compactBody}`;
|
||||
}
|
||||
|
||||
return `ARCA WSFE respondió con estado ${error.response.status}`;
|
||||
}
|
||||
|
||||
return error.message || "No pudimos conectar con ARCA WSFE";
|
||||
}
|
||||
|
||||
private extractHtmlTitle(html: string): string | undefined {
|
||||
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
|
||||
return title ? this.decodeXmlEntities(title) : undefined;
|
||||
}
|
||||
|
||||
private parseWsfeLastVoucherReturn(xml: string): number {
|
||||
const faultString = this.extractXmlValue(xml, "faultstring");
|
||||
|
||||
if (faultString) {
|
||||
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(faultString)}`);
|
||||
}
|
||||
|
||||
const voucherNumber = this.extractXmlValue(xml, "CbteNro");
|
||||
|
||||
if (!voucherNumber) {
|
||||
const errorMessage = this.extractXmlValue(xml, "Msg");
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(errorMessage)}`);
|
||||
}
|
||||
|
||||
throw new Error("ARCA WSFE no devolvió el último comprobante autorizado");
|
||||
}
|
||||
|
||||
const parsedVoucherNumber = Number(voucherNumber);
|
||||
|
||||
if (!Number.isFinite(parsedVoucherNumber)) {
|
||||
throw new Error("ARCA WSFE devolvió un número de comprobante inválido");
|
||||
}
|
||||
|
||||
return parsedVoucherNumber;
|
||||
}
|
||||
|
||||
private parseWsfeDummyReturn(xml: string): ArcaWsfeDiagnosticsResult["dummy"] {
|
||||
const errorMessage = this.extractWsfeResponseError(xml);
|
||||
|
||||
if (errorMessage) {
|
||||
return { ok: false, error: errorMessage };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
appServer: this.extractXmlValue(xml, "AppServer"),
|
||||
dbServer: this.extractXmlValue(xml, "DbServer"),
|
||||
authServer: this.extractXmlValue(xml, "AuthServer"),
|
||||
};
|
||||
}
|
||||
|
||||
private parseWsfeVoucherTypesReturn(xml: string): ArcaWsfeDiagnosticsResult["voucherTypes"] {
|
||||
const errorMessage = this.extractWsfeResponseError(xml);
|
||||
|
||||
if (errorMessage) {
|
||||
return { ok: false, error: errorMessage };
|
||||
}
|
||||
|
||||
const ids = Array.from(xml.matchAll(/<(?:\w+:)?Id>(\d+)<\/(?:\w+:)?Id>/g)).map((match) => Number(match[1]));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
count: ids.length,
|
||||
includesFacturaC: ids.includes(11),
|
||||
};
|
||||
}
|
||||
|
||||
private parseWsfePointsOfSaleReturn(
|
||||
xml: string,
|
||||
configuredPointOfSale: number
|
||||
): ArcaWsfeDiagnosticsResult["pointsOfSale"] {
|
||||
const errorMessage = this.extractWsfeResponseError(xml);
|
||||
|
||||
if (errorMessage) {
|
||||
return { ok: false, configuredPointOfSale, error: errorMessage };
|
||||
}
|
||||
|
||||
const points = Array.from(xml.matchAll(/<(?:\w+:)?PtoVenta>(\d+)<\/(?:\w+:)?PtoVenta>/g)).map((match) => Number(match[1]));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
points,
|
||||
includesConfiguredPointOfSale: points.includes(configuredPointOfSale),
|
||||
configuredPointOfSale,
|
||||
};
|
||||
}
|
||||
|
||||
private extractWsfeResponseError(xml: string): string | undefined {
|
||||
const faultString = this.extractXmlValue(xml, "faultstring");
|
||||
const msg = this.extractXmlValue(xml, "Msg");
|
||||
const errorMessage = faultString || msg;
|
||||
return errorMessage ? this.decodeXmlEntities(errorMessage) : undefined;
|
||||
}
|
||||
|
||||
private buildWsaaErrorMessage(error: unknown): string {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSAA";
|
||||
}
|
||||
|
||||
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
|
||||
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
|
||||
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
|
||||
|
||||
if (decodedFault) {
|
||||
return `ARCA WSAA rechazó la conexión: ${decodedFault}`;
|
||||
}
|
||||
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return "ARCA WSAA no respondió a tiempo. Probá nuevamente en unos minutos.";
|
||||
}
|
||||
|
||||
if (error.response?.status) {
|
||||
return `ARCA WSAA respondió con estado ${error.response.status}`;
|
||||
}
|
||||
|
||||
return error.message || "No pudimos conectar con ARCA WSAA";
|
||||
}
|
||||
|
||||
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const csr = forge.pki.createCertificationRequest();
|
||||
csr.publicKey = keys.publicKey;
|
||||
csr.setSubject([
|
||||
{ name: "commonName", value: fiscalProfile.cuit },
|
||||
{ name: "organizationName", value: fiscalProfile.legalName },
|
||||
{ name: "serialNumber", value: `CUIT ${fiscalProfile.cuit}` },
|
||||
{ name: "countryName", value: "AR" },
|
||||
]);
|
||||
csr.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
if (!csr.verify()) {
|
||||
throw new Error("No se pudo verificar el CSR generado para ARCA");
|
||||
}
|
||||
|
||||
const csrPem = forge.pki.certificationRequestToPem(csr);
|
||||
const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
||||
const encryptedPrivateKey = this.encryptPrivateKey(privateKeyPem);
|
||||
const now = new Date();
|
||||
|
||||
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
|
||||
const credentialData = {
|
||||
companyId: data.companyId,
|
||||
csrPem,
|
||||
...encryptedPrivateKey,
|
||||
encryptionKeyVersion: process.env.ARCA_CREDENTIALS_KEY_VERSION || "v1",
|
||||
status: ARCA_CREDENTIAL_STATUS.CSR_GENERATED,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const credential = existingCredential
|
||||
? await this.arcaCredentials.update(credentialData)
|
||||
: await this.arcaCredentials.create({
|
||||
...credentialData,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("No se pudieron guardar las credenciales ARCA");
|
||||
}
|
||||
|
||||
return this.sanitizeCredential(credential);
|
||||
}
|
||||
|
||||
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
|
||||
if (!data.certificatePem || !data.certificatePem.trim()) {
|
||||
throw new Error("Se requiere el certificado ARCA");
|
||||
}
|
||||
|
||||
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
|
||||
|
||||
if (
|
||||
!existingCredential ||
|
||||
!existingCredential.encryptedPrivateKey ||
|
||||
!existingCredential.privateKeyIv ||
|
||||
!existingCredential.privateKeyAuthTag
|
||||
) {
|
||||
throw new Error("Primero se deben generar las credenciales ARCA para la organización");
|
||||
}
|
||||
|
||||
const certificatePem = data.certificatePem.trim();
|
||||
let certificate: forge.pki.Certificate;
|
||||
let privateKeyPem: string;
|
||||
|
||||
try {
|
||||
certificate = this.parseCertificate(certificatePem);
|
||||
privateKeyPem = this.decryptPrivateKey(existingCredential);
|
||||
|
||||
if (!this.certificateMatchesPrivateKey(certificate, privateKeyPem)) {
|
||||
throw new Error("El certificado ARCA no corresponde a la clave privada generada para esta organización");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorOccurred = error as Error;
|
||||
await this.arcaCredentials.update({
|
||||
companyId: data.companyId,
|
||||
lastValidationError: errorOccurred.message,
|
||||
status: ARCA_CREDENTIAL_STATUS.INVALID,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
throw errorOccurred;
|
||||
}
|
||||
|
||||
const updatedCredential = await this.arcaCredentials.update({
|
||||
companyId: data.companyId,
|
||||
certificatePem,
|
||||
certificateValidFrom: certificate.validity.notBefore,
|
||||
certificateValidTo: certificate.validity.notAfter,
|
||||
lastValidationError: undefined,
|
||||
status: ARCA_CREDENTIAL_STATUS.READY,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
if (!updatedCredential) {
|
||||
throw new Error("No se pudieron actualizar las credenciales ARCA");
|
||||
}
|
||||
|
||||
return this.sanitizeCredential(updatedCredential);
|
||||
}
|
||||
|
||||
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
|
||||
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
|
||||
return {
|
||||
status: loginTicket.cached ? "cached" : "created",
|
||||
environment,
|
||||
service,
|
||||
expirationTime: loginTicket.expirationTime,
|
||||
cached: loginTicket.cached,
|
||||
};
|
||||
}
|
||||
|
||||
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const voucherType = 11;
|
||||
let responseXml: string;
|
||||
|
||||
try {
|
||||
responseXml = await this.postWsfe(
|
||||
this.buildWsfeLastVoucherSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
}),
|
||||
"FECompUltimoAutorizado"
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(this.buildWsfeErrorMessage(error));
|
||||
}
|
||||
|
||||
return {
|
||||
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
|
||||
service: ARCA_WSAA_SERVICE.WSFE,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
lastVoucherNumber: this.parseWsfeLastVoucherReturn(responseXml),
|
||||
};
|
||||
}
|
||||
|
||||
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const voucherType = 11;
|
||||
|
||||
const diagnostics: ArcaWsfeDiagnosticsResult = {
|
||||
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
|
||||
service: ARCA_WSAA_SERVICE.WSFE,
|
||||
dummy: { ok: false },
|
||||
voucherTypes: { ok: false },
|
||||
pointsOfSale: {
|
||||
ok: false,
|
||||
configuredPointOfSale: fiscalProfile.pointOfSale,
|
||||
},
|
||||
lastVoucher: {
|
||||
ok: false,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
diagnostics.dummy = this.parseWsfeDummyReturn(
|
||||
await this.postWsfe(this.buildWsfeDummySoap(), "FEDummy")
|
||||
);
|
||||
} catch (error) {
|
||||
diagnostics.dummy = { ok: false, error: this.buildWsfeErrorMessage(error) };
|
||||
}
|
||||
|
||||
try {
|
||||
diagnostics.voucherTypes = this.parseWsfeVoucherTypesReturn(
|
||||
await this.postWsfe(
|
||||
this.buildWsfeParamGetTiposCbteSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
}),
|
||||
"FEParamGetTiposCbte"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
diagnostics.voucherTypes = { ok: false, error: this.buildWsfeErrorMessage(error) };
|
||||
}
|
||||
|
||||
try {
|
||||
diagnostics.pointsOfSale = this.parseWsfePointsOfSaleReturn(
|
||||
await this.postWsfe(
|
||||
this.buildWsfeParamGetPtosVentaSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
}),
|
||||
"FEParamGetPtosVenta"
|
||||
),
|
||||
fiscalProfile.pointOfSale
|
||||
);
|
||||
} catch (error) {
|
||||
diagnostics.pointsOfSale = {
|
||||
ok: false,
|
||||
configuredPointOfSale: fiscalProfile.pointOfSale,
|
||||
error: this.buildWsfeErrorMessage(error),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
diagnostics.lastVoucher.lastVoucherNumber = this.parseWsfeLastVoucherReturn(
|
||||
await this.postWsfe(
|
||||
this.buildWsfeLastVoucherSoap({
|
||||
token: loginTicket.token,
|
||||
sign: loginTicket.sign,
|
||||
cuit: fiscalProfile.cuit,
|
||||
pointOfSale: fiscalProfile.pointOfSale,
|
||||
voucherType,
|
||||
}),
|
||||
"FECompUltimoAutorizado"
|
||||
)
|
||||
);
|
||||
diagnostics.lastVoucher.ok = true;
|
||||
} catch (error) {
|
||||
diagnostics.lastVoucher.error = this.buildWsfeErrorMessage(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
public async find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]> {
|
||||
if (!data.sessionUser) {
|
||||
throw new Error("Se requiere un usuario autenticado");
|
||||
}
|
||||
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
if (data.companyId) {
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
} else if (data.id) {
|
||||
const credential = await this.arcaCredentials.findOne(data);
|
||||
|
||||
if (!credential) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({
|
||||
companyId: String(credential.companyId),
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
return [this.sanitizeCredential(credential)];
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
||||
}
|
||||
|
||||
const credentials = await this.arcaCredentials.find(data);
|
||||
return credentials.map((credential) => this.sanitizeCredential(credential));
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArcaCredentialsManager();
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Document, FilterQuery, Model, Schema, model, models } from "mongoose";
|
||||
import {
|
||||
ARCA_WSAA_ENVIRONMENT,
|
||||
ARCA_WSAA_SERVICE,
|
||||
ArcaWsaaTokenLookupParams,
|
||||
CreateArcaWsaaTokenParams,
|
||||
IArcaWsaaToken,
|
||||
IArcaWsaaTokensAdapter,
|
||||
UpdateArcaWsaaTokenParams,
|
||||
} from "./ArcaWsaaTokens.Interface";
|
||||
|
||||
export interface IArcaWsaaTokenDocument extends Omit<IArcaWsaaToken, "id">, Document {}
|
||||
|
||||
export class ArcaWsaaTokensAdapterMongoose implements IArcaWsaaTokensAdapter {
|
||||
schema: Schema;
|
||||
arcaWsaaTokenList: Model<IArcaWsaaTokenDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
companyId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "Company",
|
||||
index: true,
|
||||
},
|
||||
service: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: Object.values(ARCA_WSAA_SERVICE),
|
||||
index: true,
|
||||
},
|
||||
environment: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: Object.values(ARCA_WSAA_ENVIRONMENT),
|
||||
index: true,
|
||||
},
|
||||
token: { type: String, required: true },
|
||||
sign: { type: String, required: true },
|
||||
generationTime: { type: Date, required: false },
|
||||
expirationTime: { type: Date, required: true, index: true },
|
||||
createdAt: { type: Date, required: true, default: Date.now },
|
||||
updatedAt: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.schema.index({ companyId: 1, service: 1, environment: 1 }, { unique: true });
|
||||
|
||||
this.arcaWsaaTokenList =
|
||||
models.ArcaWsaaToken || model<IArcaWsaaTokenDocument>("ArcaWsaaToken", this.schema);
|
||||
}
|
||||
|
||||
public async create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||
return await this.arcaWsaaTokenList.create(data);
|
||||
}
|
||||
|
||||
private buildSearchCriteria(
|
||||
filters: Partial<ArcaWsaaTokenLookupParams>
|
||||
): FilterQuery<IArcaWsaaToken> {
|
||||
const searchCriteria: FilterQuery<IArcaWsaaToken> = {};
|
||||
|
||||
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||
if (filters.service) searchCriteria.service = filters.service;
|
||||
if (filters.environment) searchCriteria.environment = filters.environment;
|
||||
|
||||
return searchCriteria;
|
||||
}
|
||||
|
||||
public async find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]> {
|
||||
return this.arcaWsaaTokenList.find(this.buildSearchCriteria(filters)).sort({ createdAt: -1 }).exec();
|
||||
}
|
||||
|
||||
public async findOne(
|
||||
filters: Partial<ArcaWsaaTokenLookupParams>
|
||||
): Promise<IArcaWsaaTokenDocument | null> {
|
||||
return this.arcaWsaaTokenList.findOne(this.buildSearchCriteria(filters)).exec();
|
||||
}
|
||||
|
||||
public async update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
|
||||
const { companyId, service, environment, ...updateData } = data;
|
||||
return this.arcaWsaaTokenList
|
||||
.findOneAndUpdate(this.buildSearchCriteria({ companyId, service, environment }), updateData, {
|
||||
new: true,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
public async upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
|
||||
const { companyId, service, environment, createdAt, ...updateData } = data;
|
||||
return this.arcaWsaaTokenList
|
||||
.findOneAndUpdate(
|
||||
this.buildSearchCriteria({ companyId, service, environment }),
|
||||
{ $set: updateData, $setOnInsert: { companyId, service, environment, createdAt } },
|
||||
{ new: true, upsert: true }
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IArcaWsaaTokenDocument } from "./ArcaWsaaTokens.Adapter.Mongoose";
|
||||
|
||||
export enum ARCA_WSAA_SERVICE {
|
||||
WSFE = "wsfe",
|
||||
}
|
||||
|
||||
export enum ARCA_WSAA_ENVIRONMENT {
|
||||
HOMOLOGATION = "homologation",
|
||||
}
|
||||
|
||||
export type ArcaWsaaTokenLookupParams = {
|
||||
companyId: string;
|
||||
service: ARCA_WSAA_SERVICE;
|
||||
environment: ARCA_WSAA_ENVIRONMENT;
|
||||
};
|
||||
|
||||
export type CreateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||
token: string;
|
||||
sign: string;
|
||||
generationTime?: Date;
|
||||
expirationTime: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UpdateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||
token?: string;
|
||||
sign?: string;
|
||||
generationTime?: Date;
|
||||
expirationTime?: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UpsertArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||
token: string;
|
||||
sign: string;
|
||||
generationTime?: Date;
|
||||
expirationTime: Date;
|
||||
};
|
||||
|
||||
export interface IArcaWsaaToken {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
service: ARCA_WSAA_SERVICE;
|
||||
environment: ARCA_WSAA_ENVIRONMENT;
|
||||
token: string;
|
||||
sign: string;
|
||||
generationTime?: Date;
|
||||
expirationTime: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IArcaWsaaTokensAdapter {
|
||||
create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||
find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]>;
|
||||
findOne(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaTokenDocument | null>;
|
||||
update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
|
||||
upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
|
||||
}
|
||||
|
||||
export interface IArcaWsaaTokensManager {
|
||||
arcaWsaaTokens: IArcaWsaaTokensAdapter;
|
||||
getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null>;
|
||||
saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||
upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ArcaWsaaTokensAdapterMongoose } from "./ArcaWsaaTokens.Adapter.Mongoose";
|
||||
import {
|
||||
ArcaWsaaTokenLookupParams,
|
||||
IArcaWsaaToken,
|
||||
IArcaWsaaTokensManager,
|
||||
UpsertArcaWsaaTokenParams,
|
||||
} from "./ArcaWsaaTokens.Interface";
|
||||
|
||||
const TOKEN_REUSE_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
class ArcaWsaaTokensManager implements IArcaWsaaTokensManager {
|
||||
arcaWsaaTokens: ArcaWsaaTokensAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.arcaWsaaTokens = new ArcaWsaaTokensAdapterMongoose();
|
||||
}
|
||||
|
||||
public async getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null> {
|
||||
const existingToken = await this.arcaWsaaTokens.findOne(data);
|
||||
|
||||
if (!existingToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reuseUntil = Date.now() + TOKEN_REUSE_SKEW_MS;
|
||||
if (new Date(existingToken.expirationTime).getTime() <= reuseUntil) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return existingToken;
|
||||
}
|
||||
|
||||
public async saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||
const now = new Date();
|
||||
return await this.arcaWsaaTokens.create({
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
public async upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||
const now = new Date();
|
||||
const token = await this.arcaWsaaTokens.upsert({
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error("No se pudo guardar el token WSAA de ARCA");
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArcaWsaaTokensManager();
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreateInvoiceParams,
|
||||
FindInvoicesParams,
|
||||
IInvoice,
|
||||
IInvoicesAdapter,
|
||||
INVOICE_STATUS,
|
||||
INVOICE_TYPE,
|
||||
} from "./Invoices.Interface";
|
||||
|
||||
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
|
||||
|
||||
export class InvoicesAdapterMongoose implements IInvoicesAdapter {
|
||||
schema: Schema;
|
||||
invoiceList: Model<IInvoiceDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
cashMovementId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "CashFlow",
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
companyId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "Company",
|
||||
index: true,
|
||||
},
|
||||
clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" },
|
||||
paymentId: { type: Schema.Types.ObjectId, required: false, ref: "Payment" },
|
||||
clientAccountMovementId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: false,
|
||||
ref: "ClientAccountMovement",
|
||||
},
|
||||
appointmentId: { type: Schema.Types.ObjectId, required: false, ref: "Appointment" },
|
||||
amount: { type: Number, required: true },
|
||||
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
|
||||
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true },
|
||||
cae: { type: String, required: false },
|
||||
caeExpiresAt: { type: Date, required: false },
|
||||
pointOfSale: { type: Number, required: false },
|
||||
voucherNumber: { type: Number, required: false },
|
||||
voucherType: { type: String, required: false },
|
||||
arcaErrorCode: { type: String, required: false },
|
||||
arcaErrorMessage: { type: String, required: false },
|
||||
createdAt: { type: Date, required: true, default: Date.now },
|
||||
updatedAt: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.invoiceList = model<IInvoiceDocument>("Invoice", this.schema);
|
||||
}
|
||||
|
||||
public async create(data: CreateInvoiceParams): Promise<IInvoice> {
|
||||
return await this.invoiceList.create(data);
|
||||
}
|
||||
|
||||
private buildSearchCriteria(filters: FindInvoicesParams): FilterQuery<IInvoice> {
|
||||
const searchCriteria: FilterQuery<IInvoice> = {};
|
||||
|
||||
if (filters.id) searchCriteria._id = filters.id;
|
||||
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId;
|
||||
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||
if (filters.status) searchCriteria.status = filters.status;
|
||||
|
||||
return searchCriteria;
|
||||
}
|
||||
|
||||
public async find(filters: FindInvoicesParams): Promise<IInvoice[]> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.invoiceList.find(searchCriteria).sort({ createdAt: -1 }).exec();
|
||||
}
|
||||
|
||||
public async findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.invoiceList.findOne(searchCriteria).exec();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { IInvoiceDocument } from "./Invoices.Adapter.Mongoose";
|
||||
|
||||
export enum INVOICE_TYPE {
|
||||
FACTURA_C = "FACTURA_C",
|
||||
}
|
||||
|
||||
export enum INVOICE_STATUS {
|
||||
PENDING_ARCA = "PENDING_ARCA",
|
||||
APPROVED = "APPROVED",
|
||||
PENDING_VERIFICATION = "PENDING_VERIFICATION",
|
||||
REJECTED = "REJECTED",
|
||||
FAILED = "FAILED",
|
||||
}
|
||||
|
||||
export type CreateInvoiceFromCashMovementParams = {
|
||||
cashMovementId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type FindInvoicesParams = {
|
||||
id?: string;
|
||||
cashMovementId?: string;
|
||||
companyId?: string;
|
||||
status?: INVOICE_STATUS;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type CreateInvoiceParams = {
|
||||
cashMovementId: string;
|
||||
companyId: string;
|
||||
clientId?: string;
|
||||
paymentId?: string;
|
||||
clientAccountMovementId?: string;
|
||||
appointmentId?: string;
|
||||
amount: number;
|
||||
type: INVOICE_TYPE;
|
||||
status: INVOICE_STATUS;
|
||||
cae?: string;
|
||||
caeExpiresAt?: Date;
|
||||
pointOfSale?: number;
|
||||
voucherNumber?: number;
|
||||
voucherType?: string;
|
||||
arcaErrorCode?: string;
|
||||
arcaErrorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface IInvoice {
|
||||
id?: string;
|
||||
cashMovementId: string;
|
||||
companyId: string;
|
||||
clientId?: string;
|
||||
paymentId?: string;
|
||||
clientAccountMovementId?: string;
|
||||
appointmentId?: string;
|
||||
amount: number;
|
||||
type: INVOICE_TYPE;
|
||||
status: INVOICE_STATUS;
|
||||
cae?: string;
|
||||
caeExpiresAt?: Date;
|
||||
pointOfSale?: number;
|
||||
voucherNumber?: number;
|
||||
voucherType?: string;
|
||||
arcaErrorCode?: string;
|
||||
arcaErrorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IInvoicesAdapter {
|
||||
create(data: CreateInvoiceParams): Promise<IInvoice>;
|
||||
find(filters: FindInvoicesParams): Promise<IInvoice[]>;
|
||||
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
|
||||
}
|
||||
|
||||
export interface IInvoicesManager {
|
||||
invoices: IInvoicesAdapter;
|
||||
createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice>;
|
||||
find(data: FindInvoicesParams): Promise<IInvoice[]>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import CashFlow from "../CashFlow/CashFlow";
|
||||
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
|
||||
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
|
||||
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
|
||||
import {
|
||||
CreateInvoiceFromCashMovementParams,
|
||||
FindInvoicesParams,
|
||||
IInvoice,
|
||||
IInvoicesManager,
|
||||
INVOICE_STATUS,
|
||||
INVOICE_TYPE,
|
||||
} from "./Invoices.Interface";
|
||||
|
||||
class InvoicesManager implements IInvoicesManager {
|
||||
invoices: InvoicesAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.invoices = new InvoicesAdapterMongoose();
|
||||
}
|
||||
|
||||
private isDuplicateKeyError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
|
||||
}
|
||||
|
||||
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
if (!data.cashMovementId || !data.cashMovementId.trim()) {
|
||||
throw new Error("Se requiere un movimiento de caja para emitir la factura");
|
||||
}
|
||||
|
||||
const movement = await CashFlow.movements.movementList.findOne({
|
||||
_id: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (!movement) {
|
||||
throw new Error("No se ha encontrado el movimiento de caja");
|
||||
}
|
||||
|
||||
const companyId = String(movement.companyId);
|
||||
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
|
||||
|
||||
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
|
||||
throw new Error("El movimiento de caja no es facturable");
|
||||
}
|
||||
|
||||
const fiscalProfiles = await OrganizationFiscalProfiles.find({
|
||||
companyId,
|
||||
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
if (fiscalProfiles.length === 0) {
|
||||
throw new Error("La organización debe tener un perfil fiscal activo para emitir facturas");
|
||||
}
|
||||
|
||||
const existingInvoice = await this.invoices.findOne({
|
||||
cashMovementId: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (existingInvoice) {
|
||||
return existingInvoice;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.invoices.create({
|
||||
cashMovementId: data.cashMovementId,
|
||||
companyId,
|
||||
clientId: movement.clientId ? String(movement.clientId) : undefined,
|
||||
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
|
||||
clientAccountMovementId: movement.clientAccountMovementId
|
||||
? String(movement.clientAccountMovementId)
|
||||
: undefined,
|
||||
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
|
||||
amount: movement.amount,
|
||||
type: INVOICE_TYPE.FACTURA_C,
|
||||
status: INVOICE_STATUS.PENDING_ARCA,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isDuplicateKeyError(error)) {
|
||||
const duplicateInvoice = await this.invoices.findOne({
|
||||
cashMovementId: data.cashMovementId,
|
||||
});
|
||||
|
||||
if (duplicateInvoice) {
|
||||
return duplicateInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
|
||||
if (!data.sessionUser) {
|
||||
throw new Error("Se requiere un usuario autenticado");
|
||||
}
|
||||
|
||||
const sessionUser = data.sessionUser;
|
||||
await validateSessionUser({ sessionUser });
|
||||
|
||||
if (data.companyId) {
|
||||
await validatePermissionsByCompany({
|
||||
companyId: data.companyId,
|
||||
sessionUser,
|
||||
});
|
||||
} else if (data.id || data.cashMovementId) {
|
||||
const invoice = await this.invoices.findOne(data);
|
||||
|
||||
if (!invoice) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({
|
||||
companyId: String(invoice.companyId),
|
||||
sessionUser,
|
||||
});
|
||||
|
||||
return [invoice];
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar facturas");
|
||||
}
|
||||
|
||||
return await this.invoices.find(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InvoicesManager();
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||
import {
|
||||
CreateOrganizationFiscalProfileParams,
|
||||
FindOrganizationFiscalProfilesParams,
|
||||
IOrganizationFiscalProfile,
|
||||
IOrganizationFiscalProfilesAdapter,
|
||||
ORGANIZATION_FISCAL_PROFILE_STATUS,
|
||||
ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION,
|
||||
UpdateOrganizationFiscalProfileParams,
|
||||
} from "./OrganizationFiscalProfiles.Interface";
|
||||
|
||||
export interface IOrganizationFiscalProfileDocument
|
||||
extends Omit<IOrganizationFiscalProfile, "id">,
|
||||
Document {}
|
||||
|
||||
export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationFiscalProfilesAdapter {
|
||||
schema: Schema;
|
||||
organizationFiscalProfileList: Model<IOrganizationFiscalProfileDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
companyId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
required: true,
|
||||
ref: "Company",
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
cuit: { type: String, required: true },
|
||||
legalName: { type: String, required: true },
|
||||
taxCondition: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION),
|
||||
},
|
||||
pointOfSale: { type: Number, required: true },
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_STATUS),
|
||||
index: true,
|
||||
},
|
||||
createdAt: { type: Date, required: true, default: Date.now },
|
||||
updatedAt: { type: Date, required: true, default: Date.now },
|
||||
});
|
||||
|
||||
this.organizationFiscalProfileList = model<IOrganizationFiscalProfileDocument>(
|
||||
"OrganizationFiscalProfile",
|
||||
this.schema
|
||||
);
|
||||
}
|
||||
|
||||
public async create(
|
||||
data: CreateOrganizationFiscalProfileParams
|
||||
): Promise<IOrganizationFiscalProfile> {
|
||||
return await this.organizationFiscalProfileList.create(data);
|
||||
}
|
||||
|
||||
private buildSearchCriteria(
|
||||
filters: FindOrganizationFiscalProfilesParams
|
||||
): FilterQuery<IOrganizationFiscalProfile> {
|
||||
const searchCriteria: FilterQuery<IOrganizationFiscalProfile> = {};
|
||||
|
||||
if (filters.id) searchCriteria._id = filters.id;
|
||||
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||
if (filters.status) searchCriteria.status = filters.status;
|
||||
|
||||
return searchCriteria;
|
||||
}
|
||||
|
||||
public async find(
|
||||
filters: FindOrganizationFiscalProfilesParams
|
||||
): Promise<IOrganizationFiscalProfile[]> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.organizationFiscalProfileList.find(searchCriteria).sort({ createdAt: -1 }).exec();
|
||||
}
|
||||
|
||||
public async findOne(
|
||||
filters: FindOrganizationFiscalProfilesParams
|
||||
): Promise<IOrganizationFiscalProfileDocument | null> {
|
||||
const searchCriteria = this.buildSearchCriteria(filters);
|
||||
return this.organizationFiscalProfileList.findOne(searchCriteria).exec();
|
||||
}
|
||||
|
||||
public async update(
|
||||
data: UpdateOrganizationFiscalProfileParams
|
||||
): Promise<IOrganizationFiscalProfileDocument | null> {
|
||||
const { id, companyId, sessionUser, ...updateData } = data;
|
||||
|
||||
if (!id && !companyId) {
|
||||
throw new Error("Se requiere un identificador para actualizar el perfil fiscal");
|
||||
}
|
||||
|
||||
return this.organizationFiscalProfileList
|
||||
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, {
|
||||
new: true,
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IOrganizationFiscalProfileDocument } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||
|
||||
export enum ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION {
|
||||
MONOTRIBUTO = "MONOTRIBUTO",
|
||||
EXENTO = "EXENTO",
|
||||
}
|
||||
|
||||
export enum ORGANIZATION_FISCAL_PROFILE_STATUS {
|
||||
PENDING = "PENDING",
|
||||
ACTIVE = "ACTIVE",
|
||||
DISABLED = "DISABLED",
|
||||
}
|
||||
|
||||
export type UpsertOrganizationFiscalProfileParams = {
|
||||
companyId: string;
|
||||
cuit: string;
|
||||
legalName: string;
|
||||
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||
pointOfSale: number;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type FindOrganizationFiscalProfilesParams = {
|
||||
id?: string;
|
||||
companyId?: string;
|
||||
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||
sessionUser?: string;
|
||||
};
|
||||
|
||||
export type CreateOrganizationFiscalProfileParams = {
|
||||
companyId: string;
|
||||
cuit: string;
|
||||
legalName: string;
|
||||
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||
pointOfSale: number;
|
||||
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type UpdateOrganizationFiscalProfileParams = FindOrganizationFiscalProfilesParams & {
|
||||
cuit?: string;
|
||||
legalName?: string;
|
||||
taxCondition?: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||
pointOfSale?: number;
|
||||
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export interface IOrganizationFiscalProfile {
|
||||
id?: string;
|
||||
companyId: string;
|
||||
cuit: string;
|
||||
legalName: string;
|
||||
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||
pointOfSale: number;
|
||||
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IOrganizationFiscalProfilesAdapter {
|
||||
create(data: CreateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
|
||||
find(filters: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
|
||||
findOne(
|
||||
filters: FindOrganizationFiscalProfilesParams
|
||||
): Promise<IOrganizationFiscalProfileDocument | null>;
|
||||
update(data: UpdateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfileDocument | null>;
|
||||
}
|
||||
|
||||
export interface IOrganizationFiscalProfilesManager {
|
||||
organizationFiscalProfiles: IOrganizationFiscalProfilesAdapter;
|
||||
upsert(data: UpsertOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
|
||||
find(data: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||
import {
|
||||
FindOrganizationFiscalProfilesParams,
|
||||
IOrganizationFiscalProfile,
|
||||
IOrganizationFiscalProfilesManager,
|
||||
ORGANIZATION_FISCAL_PROFILE_STATUS,
|
||||
UpsertOrganizationFiscalProfileParams,
|
||||
} from "./OrganizationFiscalProfiles.Interface";
|
||||
|
||||
class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesManager {
|
||||
organizationFiscalProfiles: OrganizationFiscalProfilesAdapterMongoose;
|
||||
|
||||
constructor() {
|
||||
this.organizationFiscalProfiles = new OrganizationFiscalProfilesAdapterMongoose();
|
||||
}
|
||||
|
||||
private isDuplicateKeyError(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
|
||||
}
|
||||
|
||||
private validateProfileFields(data: UpsertOrganizationFiscalProfileParams): void {
|
||||
if (!/^\d{11}$/.test(data.cuit)) {
|
||||
throw new Error("El CUIT debe contener 11 dígitos");
|
||||
}
|
||||
|
||||
if (!data.legalName || !data.legalName.trim()) {
|
||||
throw new Error("La razón social es requerida");
|
||||
}
|
||||
|
||||
if (!data.pointOfSale || data.pointOfSale <= 0) {
|
||||
throw new Error("El punto de venta debe ser mayor a 0");
|
||||
}
|
||||
}
|
||||
|
||||
public async upsert(
|
||||
data: UpsertOrganizationFiscalProfileParams
|
||||
): Promise<IOrganizationFiscalProfile> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
if (!data.companyId || !data.companyId.trim()) {
|
||||
throw new Error("Se requiere una organización para guardar el perfil fiscal");
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
this.validateProfileFields(data);
|
||||
|
||||
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||
companyId: data.companyId,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
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,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!updatedProfile) {
|
||||
throw new Error("No se pudo actualizar el perfil fiscal");
|
||||
}
|
||||
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch (error) {
|
||||
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,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
if (updatedProfile) {
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||
companyId: data.companyId,
|
||||
});
|
||||
|
||||
if (existingProfile) {
|
||||
return existingProfile;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async find(
|
||||
data: FindOrganizationFiscalProfilesParams
|
||||
): Promise<IOrganizationFiscalProfile[]> {
|
||||
if (!data.sessionUser) {
|
||||
throw new Error("Se requiere un usuario autenticado");
|
||||
}
|
||||
|
||||
const sessionUser = data.sessionUser;
|
||||
await validateSessionUser({ sessionUser });
|
||||
|
||||
if (data.companyId) {
|
||||
await validatePermissionsByCompany({
|
||||
companyId: data.companyId,
|
||||
sessionUser,
|
||||
});
|
||||
} else if (data.id) {
|
||||
const profile = await this.organizationFiscalProfiles.findOne(data);
|
||||
|
||||
if (!profile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await validatePermissionsByCompany({
|
||||
companyId: String(profile.companyId),
|
||||
sessionUser,
|
||||
});
|
||||
|
||||
return [profile];
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
||||
}
|
||||
|
||||
return await this.organizationFiscalProfiles.find(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OrganizationFiscalProfilesManager();
|
||||
Reference in New Issue
Block a user