diff --git a/server/.env b/server/.env index d37bcd7..24bf0b2 100644 --- a/server/.env +++ b/server/.env @@ -69,3 +69,7 @@ FILE_SERVER_URL = http://localhost:4000/ FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/ SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key +ARCA_WSAA_HOMOLOGATION_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms +ARCA_WSFE_HOMOLOGATION_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx + +ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= diff --git a/server/.env.prod b/server/.env.prod index 2d675ad..d6680b7 100644 --- a/server/.env.prod +++ b/server/.env.prod @@ -67,3 +67,7 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997 FILE_SERVER_URL = https://files.turnosxpress.com.ar/ SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key +ARCA_WSAA_HOMOLOGATION_URL=https://wsaahomo.afip.gov.ar/ws/services/LoginCms +ARCA_WSFE_HOMOLOGATION_URL=https://wswhomo.afip.gov.ar/wsfev1/service.asmx + +ARCA_CREDENTIALS_MASTER_KEY=ZmFrZS1kZXYtYXJjYS1tYXN0ZXIta2V5LTEyMzQ1Njc= diff --git a/server/package-lock.json b/server/package-lock.json index 7064c03..c2f81a9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.3", "mongoose": "8.8.3", "multer": "^1.4.5-lts.2", + "node-forge": "^1.4.0", "nodemailer": "^6.10.1", "sanitize-html": "^2.17.6", "sharp": "^0.33.5", @@ -33,6 +34,7 @@ "@types/express": "^4.17.25", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/node-forge": "^1.3.14", "@types/nodemailer": "^6.4.24", "@types/sanitize-html": "^2.16.1", "@types/socket.io": "^3.0.2", @@ -775,9 +777,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -794,9 +793,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -813,9 +809,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -832,9 +825,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -851,9 +841,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -870,9 +857,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -889,9 +873,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -914,9 +895,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -939,9 +917,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -964,9 +939,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -989,9 +961,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1014,9 +983,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2119,6 +2085,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==" }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.24", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", @@ -6672,6 +6648,38 @@ "url": "https://opencollective.com/mongoose" } }, + "node_modules/mongoose/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose/node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/mongoose/node_modules/mongodb": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz", @@ -6723,6 +6731,56 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/mongoose/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/mongoose/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/mongoose/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, + "node_modules/mongoose/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -6892,6 +6950,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/server/package.json b/server/package.json index ff7a1a1..a797f1e 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "@types/express": "^4.17.25", "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/node-forge": "^1.3.14", "@types/nodemailer": "^6.4.24", "@types/sanitize-html": "^2.16.1", "@types/socket.io": "^3.0.2", @@ -42,6 +43,7 @@ "jsonwebtoken": "^9.0.3", "mongoose": "8.8.3", "multer": "^1.4.5-lts.2", + "node-forge": "^1.4.0", "nodemailer": "^6.10.1", "sanitize-html": "^2.17.6", "sharp": "^0.33.5", diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.Adapter.Mongoose.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.Adapter.Mongoose.ts new file mode 100644 index 0000000..7faaa9a --- /dev/null +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.Adapter.Mongoose.ts @@ -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, Document {} + +export class ArcaCredentialsAdapterMongoose implements IArcaCredentialsAdapter { + schema: Schema; + arcaCredentialList: Model; + + 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("ArcaCredential", this.schema); + } + + public async create(data: CreateArcaCredentialParams): Promise { + return await this.arcaCredentialList.create(data); + } + + private buildSearchCriteria(filters: FindArcaCredentialsParams): FilterQuery { + const searchCriteria: FilterQuery = {}; + + 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 { + const searchCriteria = this.buildSearchCriteria(filters); + return this.arcaCredentialList.find(searchCriteria).sort({ createdAt: -1 }).exec(); + } + + public async findOne(filters: FindArcaCredentialsParams): Promise { + const searchCriteria = this.buildSearchCriteria(filters); + return this.arcaCredentialList.findOne(searchCriteria).exec(); + } + + public async update(data: UpdateArcaCredentialParams): Promise { + 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(); + } +} diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts new file mode 100644 index 0000000..0ad24d6 --- /dev/null +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.Interface.ts @@ -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; + find(filters: FindArcaCredentialsParams): Promise; + findOne(filters: FindArcaCredentialsParams): Promise; + update(data: UpdateArcaCredentialParams): Promise; +} + +export interface IArcaCredentialsManager { + arcaCredentials: IArcaCredentialsAdapter; + generateCsr(data: GenerateArcaCredentialCsrParams): Promise; + uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise; + testWsaaLogin(data: TestArcaWsaaLoginParams): Promise; + getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise; + getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise; + find(data: FindArcaCredentialsParams): Promise; +} diff --git a/server/src/Models/ArcaCredentials/ArcaCredentials.ts b/server/src/Models/ArcaCredentials/ArcaCredentials.ts new file mode 100644 index 0000000..0384d58 --- /dev/null +++ b/server/src/Models/ArcaCredentials/ArcaCredentials.ts @@ -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 { + 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 { + 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: `\n\n
\n${uniqueId}\n${this.formatWsaaDate(generationTime)}\n${this.formatWsaaDate(expirationTime)}\n
\n${service}\n
`, + }; + } + + 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 `\n\n\n\n\n${cms}\n\n\n`; + } + + private extractXmlValue(xml: string, tagName: string): string | undefined { + const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)`)); + 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 ` + + + + + + ${data.token} + ${data.sign} + ${data.cuit} + + ${data.pointOfSale} + ${data.voucherType} + + + `; + } + + private buildWsfeDummySoap(): string { + return ` + + + + + + `; + } + + private buildWsfeParamGetTiposCbteSoap(data: { token: string; sign: string; cuit: string }): string { + return ` + + + + + + ${data.token} + ${data.sign} + ${data.cuit} + + + + `; + } + + private buildWsfeParamGetPtosVentaSoap(data: { token: string; sign: string; cuit: string }): string { + return ` + + + + + + ${data.token} + ${data.sign} + ${data.cuit} + + + + `; + } + + private async postWsfe(soapBody: string, soapAction: string): Promise { + 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(/]*>([\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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Adapter.Mongoose.ts b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Adapter.Mongoose.ts new file mode 100644 index 0000000..26d4396 --- /dev/null +++ b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Adapter.Mongoose.ts @@ -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, Document {} + +export class ArcaWsaaTokensAdapterMongoose implements IArcaWsaaTokensAdapter { + schema: Schema; + arcaWsaaTokenList: Model; + + 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("ArcaWsaaToken", this.schema); + } + + public async create(data: CreateArcaWsaaTokenParams): Promise { + return await this.arcaWsaaTokenList.create(data); + } + + private buildSearchCriteria( + filters: Partial + ): FilterQuery { + const searchCriteria: FilterQuery = {}; + + 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): Promise { + return this.arcaWsaaTokenList.find(this.buildSearchCriteria(filters)).sort({ createdAt: -1 }).exec(); + } + + public async findOne( + filters: Partial + ): Promise { + return this.arcaWsaaTokenList.findOne(this.buildSearchCriteria(filters)).exec(); + } + + public async update(data: UpdateArcaWsaaTokenParams): Promise { + 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 { + 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(); + } +} diff --git a/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Interface.ts b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Interface.ts new file mode 100644 index 0000000..f5feb6d --- /dev/null +++ b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.Interface.ts @@ -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; + find(filters: Partial): Promise; + findOne(filters: Partial): Promise; + update(data: UpdateArcaWsaaTokenParams): Promise; + upsert(data: CreateArcaWsaaTokenParams): Promise; +} + +export interface IArcaWsaaTokensManager { + arcaWsaaTokens: IArcaWsaaTokensAdapter; + getValidToken(data: ArcaWsaaTokenLookupParams): Promise; + saveToken(data: UpsertArcaWsaaTokenParams): Promise; + upsertToken(data: UpsertArcaWsaaTokenParams): Promise; +} diff --git a/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.ts b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.ts new file mode 100644 index 0000000..eed2607 --- /dev/null +++ b/server/src/Models/ArcaWsaaTokens/ArcaWsaaTokens.ts @@ -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 { + 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 { + const now = new Date(); + return await this.arcaWsaaTokens.create({ + ...data, + createdAt: now, + updatedAt: now, + }); + } + + public async upsertToken(data: UpsertArcaWsaaTokenParams): Promise { + 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(); diff --git a/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts new file mode 100644 index 0000000..4e5bb6d --- /dev/null +++ b/server/src/Models/Invoices/Invoices.Adapter.Mongoose.ts @@ -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, Document {} + +export class InvoicesAdapterMongoose implements IInvoicesAdapter { + schema: Schema; + invoiceList: Model; + + 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("Invoice", this.schema); + } + + public async create(data: CreateInvoiceParams): Promise { + return await this.invoiceList.create(data); + } + + private buildSearchCriteria(filters: FindInvoicesParams): FilterQuery { + const searchCriteria: FilterQuery = {}; + + 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 { + const searchCriteria = this.buildSearchCriteria(filters); + return this.invoiceList.find(searchCriteria).sort({ createdAt: -1 }).exec(); + } + + public async findOne(filters: FindInvoicesParams): Promise { + const searchCriteria = this.buildSearchCriteria(filters); + return this.invoiceList.findOne(searchCriteria).exec(); + } + +} diff --git a/server/src/Models/Invoices/Invoices.Interface.ts b/server/src/Models/Invoices/Invoices.Interface.ts new file mode 100644 index 0000000..30e941e --- /dev/null +++ b/server/src/Models/Invoices/Invoices.Interface.ts @@ -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; + find(filters: FindInvoicesParams): Promise; + findOne(filters: FindInvoicesParams): Promise; +} + +export interface IInvoicesManager { + invoices: IInvoicesAdapter; + createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise; + find(data: FindInvoicesParams): Promise; +} diff --git a/server/src/Models/Invoices/Invoices.ts b/server/src/Models/Invoices/Invoices.ts new file mode 100644 index 0000000..0d3e0a7 --- /dev/null +++ b/server/src/Models/Invoices/Invoices.ts @@ -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 { + 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 { + 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(); diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts new file mode 100644 index 0000000..5b38d50 --- /dev/null +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Adapter.Mongoose.ts @@ -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, + Document {} + +export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationFiscalProfilesAdapter { + schema: Schema; + organizationFiscalProfileList: Model; + + 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( + "OrganizationFiscalProfile", + this.schema + ); + } + + public async create( + data: CreateOrganizationFiscalProfileParams + ): Promise { + return await this.organizationFiscalProfileList.create(data); + } + + private buildSearchCriteria( + filters: FindOrganizationFiscalProfilesParams + ): FilterQuery { + const searchCriteria: FilterQuery = {}; + + 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 { + const searchCriteria = this.buildSearchCriteria(filters); + return this.organizationFiscalProfileList.find(searchCriteria).sort({ createdAt: -1 }).exec(); + } + + public async findOne( + filters: FindOrganizationFiscalProfilesParams + ): Promise { + const searchCriteria = this.buildSearchCriteria(filters); + return this.organizationFiscalProfileList.findOne(searchCriteria).exec(); + } + + public async update( + data: UpdateOrganizationFiscalProfileParams + ): Promise { + 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(); + } +} diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts new file mode 100644 index 0000000..4b3b8f3 --- /dev/null +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface.ts @@ -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; + find(filters: FindOrganizationFiscalProfilesParams): Promise; + findOne( + filters: FindOrganizationFiscalProfilesParams + ): Promise; + update(data: UpdateOrganizationFiscalProfileParams): Promise; +} + +export interface IOrganizationFiscalProfilesManager { + organizationFiscalProfiles: IOrganizationFiscalProfilesAdapter; + upsert(data: UpsertOrganizationFiscalProfileParams): Promise; + find(data: FindOrganizationFiscalProfilesParams): Promise; +} diff --git a/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts new file mode 100644 index 0000000..78c1c9a --- /dev/null +++ b/server/src/Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.ts @@ -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 { + 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 { + 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(); diff --git a/server/src/api/ArcaCredentials/ArcaCredentials.Controller.ts b/server/src/api/ArcaCredentials/ArcaCredentials.Controller.ts new file mode 100644 index 0000000..7098cce --- /dev/null +++ b/server/src/api/ArcaCredentials/ArcaCredentials.Controller.ts @@ -0,0 +1,142 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { + ArcaCredentialView, + ArcaWsfeDiagnosticsParams, + ArcaWsfeDiagnosticsResult, + ArcaWsfeLastVoucherParams, + ArcaWsfeLastVoucherResult, + ArcaWsaaLoginTestResult, + FindArcaCredentialsParams, + GenerateArcaCredentialCsrParams, + TestArcaWsaaLoginParams, + UploadArcaCredentialCertificateParams, +} from "../../Models/ArcaCredentials/ArcaCredentials.Interface"; +import { ArcaCredentialsService } from "./ArcaCredentials.Service"; + +@Route("arca-credentials/generate-csr") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsGenerateCsrController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async generateCsr( + @Body() requestBody: GenerateArcaCredentialCsrParams + ): Promise { + try { + const credential = await new ArcaCredentialsService().generateCsr(requestBody); + this.setStatus(200); + return credential; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("arca-credentials/upload-certificate") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsUploadCertificateController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async uploadCertificate( + @Body() requestBody: UploadArcaCredentialCertificateParams + ): Promise { + try { + const credential = await new ArcaCredentialsService().uploadCertificate(requestBody); + this.setStatus(200); + return credential; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("arca-credentials/test-wsaa-login") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsTestWsaaLoginController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async testWsaaLogin( + @Body() requestBody: TestArcaWsaaLoginParams + ): Promise { + try { + const result = await new ArcaCredentialsService().testWsaaLogin(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("arca-credentials/wsfe-last-voucher") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsWsfeLastVoucherController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async getWsfeLastVoucher( + @Body() requestBody: ArcaWsfeLastVoucherParams + ): Promise { + try { + const result = await new ArcaCredentialsService().getWsfeLastVoucher(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("arca-credentials/wsfe-diagnostics") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsWsfeDiagnosticsController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async getWsfeDiagnostics( + @Body() requestBody: ArcaWsfeDiagnosticsParams + ): Promise { + try { + const result = await new ArcaCredentialsService().getWsfeDiagnostics(requestBody); + this.setStatus(200); + return result; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("arca-credentials/find") +@Middlewares(authenticateMiddleware) +export class ArcaCredentialsFindController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async find( + @Body() requestBody: FindArcaCredentialsParams + ): Promise { + try { + const credentials = await new ArcaCredentialsService().find(requestBody); + this.setStatus(200); + return credentials; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/ArcaCredentials/ArcaCredentials.Service.ts b/server/src/api/ArcaCredentials/ArcaCredentials.Service.ts new file mode 100644 index 0000000..de54ce6 --- /dev/null +++ b/server/src/api/ArcaCredentials/ArcaCredentials.Service.ts @@ -0,0 +1,46 @@ +import { connect } from "mongoose"; +import ArcaCredentials from "../../Models/ArcaCredentials/ArcaCredentials"; +import { + ArcaCredentialView, + ArcaWsfeDiagnosticsParams, + ArcaWsfeDiagnosticsResult, + ArcaWsfeLastVoucherParams, + ArcaWsfeLastVoucherResult, + ArcaWsaaLoginTestResult, + FindArcaCredentialsParams, + GenerateArcaCredentialCsrParams, + TestArcaWsaaLoginParams, + UploadArcaCredentialCertificateParams, +} from "../../Models/ArcaCredentials/ArcaCredentials.Interface"; + +export class ArcaCredentialsService { + public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.generateCsr(data); + } + + public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.uploadCertificate(data); + } + + public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.testWsaaLogin(data); + } + + public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.getWsfeLastVoucher(data); + } + + public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.getWsfeDiagnostics(data); + } + + public async find(data: FindArcaCredentialsParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await ArcaCredentials.find(data); + } +} diff --git a/server/src/api/Invoices/Invoices.Controller.ts b/server/src/api/Invoices/Invoices.Controller.ts new file mode 100644 index 0000000..cb41aa8 --- /dev/null +++ b/server/src/api/Invoices/Invoices.Controller.ts @@ -0,0 +1,49 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { + CreateInvoiceFromCashMovementParams, + FindInvoicesParams, + IInvoice, +} from "../../Models/Invoices/Invoices.Interface"; +import { InvoicesService } from "./Invoices.Service"; + +@Route("invoices/create-from-cash-movement") +@Middlewares(authenticateMiddleware) +export class InvoicesCreateFromCashMovementController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async createFromCashMovement( + @Body() requestBody: CreateInvoiceFromCashMovementParams + ): Promise { + try { + const invoice = await new InvoicesService().createFromCashMovement(requestBody); + this.setStatus(200); + return invoice; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("invoices/find") +@Middlewares(authenticateMiddleware) +export class InvoicesFindController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async find(@Body() requestBody: FindInvoicesParams): Promise { + try { + const invoices = await new InvoicesService().find(requestBody); + this.setStatus(200); + return invoices; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/Invoices/Invoices.Service.ts b/server/src/api/Invoices/Invoices.Service.ts new file mode 100644 index 0000000..538fa6f --- /dev/null +++ b/server/src/api/Invoices/Invoices.Service.ts @@ -0,0 +1,19 @@ +import { connect } from "mongoose"; +import Invoices from "../../Models/Invoices/Invoices"; +import { + CreateInvoiceFromCashMovementParams, + FindInvoicesParams, + IInvoice, +} from "../../Models/Invoices/Invoices.Interface"; + +export class InvoicesService { + public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await Invoices.createFromCashMovement(data); + } + + public async find(data: FindInvoicesParams): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await Invoices.find(data); + } +} diff --git a/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Controller.ts b/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Controller.ts new file mode 100644 index 0000000..2087ea1 --- /dev/null +++ b/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa"; +import { authenticateMiddleware } from "../../middleware/authentication"; +import { ApiValidationError } from "../../Models/Server.Error.model"; +import { + FindOrganizationFiscalProfilesParams, + IOrganizationFiscalProfile, + UpsertOrganizationFiscalProfileParams, +} from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface"; +import { OrganizationFiscalProfilesService } from "./OrganizationFiscalProfiles.Service"; + +@Route("organization-fiscal-profiles/upsert") +@Middlewares(authenticateMiddleware) +export class OrganizationFiscalProfilesUpsertController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async upsert( + @Body() requestBody: UpsertOrganizationFiscalProfileParams + ): Promise { + try { + const profile = await new OrganizationFiscalProfilesService().upsert(requestBody); + this.setStatus(200); + return profile; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} + +@Route("organization-fiscal-profiles/find") +@Middlewares(authenticateMiddleware) +export class OrganizationFiscalProfilesFindController extends Controller { + @SuccessResponse(200, "Done") + @Response(500, "Ha ocurrido un error") + @Post() + public async find( + @Body() requestBody: FindOrganizationFiscalProfilesParams + ): Promise { + try { + const profiles = await new OrganizationFiscalProfilesService().find(requestBody); + this.setStatus(200); + return profiles; + } catch (e) { + const errorOccurred: Error = e as Error; + this.setStatus(500); + return new ApiValidationError(500, errorOccurred.message); + } + } +} diff --git a/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Service.ts b/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Service.ts new file mode 100644 index 0000000..7cba6f2 --- /dev/null +++ b/server/src/api/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Service.ts @@ -0,0 +1,23 @@ +import { connect } from "mongoose"; +import OrganizationFiscalProfiles from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles"; +import { + FindOrganizationFiscalProfilesParams, + IOrganizationFiscalProfile, + UpsertOrganizationFiscalProfileParams, +} from "../../Models/OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface"; + +export class OrganizationFiscalProfilesService { + public async upsert( + data: UpsertOrganizationFiscalProfileParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await OrganizationFiscalProfiles.upsert(data); + } + + public async find( + data: FindOrganizationFiscalProfilesParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return await OrganizationFiscalProfiles.find(data); + } +} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx index 7911e7c..40ce401 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/Cash.Service.tsx @@ -13,6 +13,19 @@ import { import { ApiError } from "@core/Models/Server.Error.model"; import * as Yup from "yup"; +export type ArcaWsfeLastVoucherParams = { + companyId: string; + sessionUser: string; +}; + +export type ArcaWsfeLastVoucherResult = { + environment: "homologation"; + service: "wsfe"; + pointOfSale: number; + voucherType: 11; + lastVoucherNumber: number; +}; + const schemaCreateCashFlowMovement = Yup.object().shape({ paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."), companyId: Yup.string().required("La organización no es valida."), @@ -72,3 +85,9 @@ export const cashFlowPaginate = async ( export const cashFlowPaginateBalance = async (data: FindCashFlowBalanceParams): Promise => { return ApiRequest.post("cashflow/paginate-balance", data); }; + +export const getArcaWsfeLastVoucher = async ( + data: ArcaWsfeLastVoucherParams +): Promise => { + return ApiRequest.post("arca-credentials/wsfe-last-voucher", data); +}; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/page.tsx index ea50e5a..ebb131b 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/cash/page.tsx @@ -13,7 +13,7 @@ import ButtonMaterial from "@mui/material/Button"; import { useNavigation } from "@core/app/hooks/goto"; import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/CashFlow.model"; import { useSessionStore } from "@core/Store/Sesion.Store"; -import { cashFlowPaginate } from "./Cash.Service"; +import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service"; import { useAlert } from "@core/Store/Alert.Store"; import { IconButton } from "@mui/material"; import RetirarIcon from "@mui/icons-material/CallMadeOutlined"; @@ -42,6 +42,10 @@ export default function OrganizationCashFlowDetail() { const [dateFrom, setDateFrom] = useState(dayjs().startOf("day")); const [dateTo, setDateTo] = useState(dayjs().endOf("day")); const [view, setView] = useState(null); + const [arcaLastVoucher, setArcaLastVoucher] = useState(null); + const [arcaLastVoucherLoading, setArcaLastVoucherLoading] = useState(false); + const [arcaLastVoucherError, setArcaLastVoucherError] = useState(false); + const [arcaLastVoucherErrorMessage, setArcaLastVoucherErrorMessage] = useState(""); useEffect(() => { eventHandler.setEventType(EVENT_TYPES.SLEEP); @@ -73,6 +77,10 @@ export default function OrganizationCashFlowDetail() { cargarDatos(); }, [SessionInfo.userId, id, dateFrom, dateTo]); + useEffect(() => { + loadArcaLastVoucher(); + }, [SessionInfo.userId, id]); + const cargarDatos = async () => { eventHandler.setEventType(EVENT_TYPES.LOADING); @@ -110,6 +118,34 @@ export default function OrganizationCashFlowDetail() { }); }; + const loadArcaLastVoucher = async () => { + if (!SessionInfo.userId || !id) { + return; + } + + setArcaLastVoucherLoading(true); + setArcaLastVoucherError(false); + setArcaLastVoucherErrorMessage(""); + + getArcaWsfeLastVoucher({ + companyId: id, + sessionUser: SessionInfo.userId, + }) + .then((data) => { + setArcaLastVoucher(data); + }) + .catch((error) => { + setArcaLastVoucher(null); + setArcaLastVoucherError(true); + setArcaLastVoucherErrorMessage( + error?.desc || error?.message || "No pudimos consultar ARCA" + ); + }) + .finally(() => { + setArcaLastVoucherLoading(false); + }); + }; + const add = (type: CLIENT_ACCOUNT_MOVEMENT_TYPES) => { goTo("/admin/org/profile/" + id + "/cash/add/" + type); }; @@ -175,6 +211,40 @@ export default function OrganizationCashFlowDetail() { > {formatPrice(view.balance)} +
+ {arcaLastVoucherLoading ? ( + "Consultando último comprobante ARCA..." + ) : arcaLastVoucher ? ( + <> + Última Factura C autorizada ARCA: #{" "} + {arcaLastVoucher.lastVoucherNumber} + {" · "}PV {arcaLastVoucher.pointOfSale} + + ) : arcaLastVoucherError ? ( + <> + No pudimos consultar ARCA + {arcaLastVoucherErrorMessage && ( +
+ {arcaLastVoucherErrorMessage} +
+ )} + + ) : ( + "ARCA: esperando consulta del último comprobante" + )} +
(null); const [wapError, setWapError] = useState(null); + // State for ARCA onboarding flow + const [arcaCuit, setArcaCuit] = useState(""); + const [arcaLegalName, setArcaLegalName] = useState(""); + const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO"); + const [arcaPointOfSale, setArcaPointOfSale] = useState("1"); + const [arcaCsrPem, setArcaCsrPem] = useState(""); + const [arcaCertificatePem, setArcaCertificatePem] = useState(""); + const [arcaStatusLabel, setArcaStatusLabel] = useState(""); + const [arcaLoading, setArcaLoading] = useState(false); + const [arcaDiagnosticsLoading, setArcaDiagnosticsLoading] = useState(false); + const [arcaError, setArcaError] = useState(null); + const [arcaTestResult, setArcaTestResult] = useState(null); + const [arcaDiagnosticsResult, setArcaDiagnosticsResult] = useState(null); + // State for Setup Collaborator Flow const [collaboratorToSetup, setCollaboratorToSetup] = useState(null); const [setupMissingServices, setSetupMissingServices] = useState(false); @@ -392,11 +408,43 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm setWapView(null); setWapQr(null); setWapError(null); + } else if (flowId === "arca-onboarding") { + setCurrentStepIndex(0); + setArcaError(null); + setArcaLoading(false); + setArcaTestResult(null); + setArcaCertificatePem(""); + API.post("organization-fiscal-profiles/find", { + companyId: org.id || org._id, + sessionUser: SessionInfo.userId + }).then(res => { + const profile = res?.[0]; + if (!profile) return; + setArcaCuit(profile.cuit || ""); + setArcaLegalName(profile.legalName || ""); + setArcaTaxCondition(profile.taxCondition || "MONOTRIBUTO"); + setArcaPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1"); + }).catch(console.error); + API.post("arca-credentials/find", { + companyId: org.id || org._id, + sessionUser: SessionInfo.userId + }).then(res => { + const credential = res?.[0]; + if (!credential) return; + setArcaCsrPem(credential.csrPem || ""); + setArcaStatusLabel(getArcaStatusLabel(credential.status)); + }).catch(console.error); + } else if (flowId === "arca-test-connection") { + setCurrentStepIndex(0); + setArcaError(null); + setArcaLoading(false); + setArcaTestResult(null); + setArcaDiagnosticsResult(null); } }; React.useEffect(() => { - if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") { + if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") { setIsResuming(true); API.post("companies/get-by-user", { sessionUser: SessionInfo.userId }) .then(response => { @@ -417,7 +465,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm .finally(() => setIsResuming(false)); } - if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) { + if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) { setCurrentStepIndex(0); } }, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]); @@ -758,6 +806,185 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm /> ); + const getArcaStatusLabel = (status?: string) => { + if (status === "READY") return "Certificado validado. La conexión con ARCA quedó lista."; + if (status === "CERTIFICATE_UPLOADED") return "Certificado cargado. Estamos listos para validar la conexión."; + if (status === "CSR_GENERATED") return "CSR generado. Descargalo y subilo en ARCA para obtener el certificado."; + if (status === "INVALID") return "El certificado cargado no pudo validarse. Revisá que corresponda al CSR generado."; + return ""; + }; + + const handleSaveArcaFiscalProfile = async () => { + if (!createdCompanyId || arcaLoading) return; + setArcaLoading(true); + setArcaError(null); + try { + await API.post("organization-fiscal-profiles/upsert", { + companyId: createdCompanyId, + cuit: arcaCuit, + legalName: arcaLegalName, + taxCondition: arcaTaxCondition, + pointOfSale: Number(arcaPointOfSale), + sessionUser: SessionInfo.userId + }); + handleNext(); + } catch (error: any) { + console.error("Error guardando datos fiscales ARCA:", error); + setArcaError(error?.message || "No pudimos guardar los datos fiscales. Revisá la información y volvé a intentar."); + } finally { + setArcaLoading(false); + } + }; + + const handleGenerateArcaCsr = async () => { + if (!createdCompanyId || arcaLoading) return; + setArcaLoading(true); + setArcaError(null); + try { + const credential = await API.post("arca-credentials/generate-csr", { + companyId: createdCompanyId, + sessionUser: SessionInfo.userId + }); + const generatedCsr = credential?.csrPem || ""; + setArcaCsrPem(generatedCsr); + setArcaStatusLabel(getArcaStatusLabel(credential?.status)); + if (generatedCsr) { + downloadArcaCsr(generatedCsr); + } + } catch (error: any) { + console.error("Error generando CSR ARCA:", error); + setArcaError(error?.message || "No pudimos generar el CSR. Intentá nuevamente."); + } finally { + setArcaLoading(false); + } + }; + + const downloadArcaCsr = (csrPem: string) => { + const blob = new Blob([csrPem], { type: "application/pkcs10" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `turnosxpress-arca-${arcaCuit || createdCompanyId}.csr`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const handleDownloadArcaCsr = () => { + if (!arcaCsrPem) return; + downloadArcaCsr(arcaCsrPem); + }; + + const handleArcaCertificateFile = (file: File | null) => { + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setArcaCertificatePem(String(reader.result || "")); + reader.onerror = () => setArcaError("No pudimos leer el archivo. Probá pegando el contenido del certificado."); + reader.readAsText(file); + }; + + const handleUploadArcaCertificate = async () => { + if (!createdCompanyId || arcaLoading || !arcaCertificatePem.trim()) return; + setArcaLoading(true); + setArcaError(null); + try { + const credential = await API.post("arca-credentials/upload-certificate", { + companyId: createdCompanyId, + certificatePem: arcaCertificatePem, + sessionUser: SessionInfo.userId + }); + setArcaStatusLabel(getArcaStatusLabel(credential?.status)); + setCurrentStepIndex(5); + } catch (error: any) { + console.error("Error cargando certificado ARCA:", error); + setArcaError(error?.message || "No pudimos guardar el certificado. Revisá que sea el archivo emitido por ARCA."); + } finally { + setArcaLoading(false); + } + }; + + const handleTestArcaConnection = async () => { + if (!createdCompanyId || arcaLoading) return; + setArcaLoading(true); + setArcaError(null); + setArcaTestResult(null); + setArcaDiagnosticsResult(null); + try { + const result = await API.post("arca-credentials/test-wsaa-login", { + companyId: createdCompanyId, + sessionUser: SessionInfo.userId + }); + setArcaTestResult(result); + setCurrentStepIndex(1); + setArcaDiagnosticsLoading(true); + try { + const diagnostics = await API.post("arca-credentials/wsfe-diagnostics", { + companyId: createdCompanyId, + sessionUser: SessionInfo.userId + }); + setArcaDiagnosticsResult(diagnostics); + } catch (diagnosticsError: any) { + console.error("Error consultando diagnóstico WSFE ARCA:", diagnosticsError); + setArcaDiagnosticsResult({ + dummy: { ok: false, error: diagnosticsError?.message || "No pudimos consultar WSFE." }, + voucherTypes: { ok: false, error: diagnosticsError?.message || "No pudimos consultar tipos de comprobante." }, + pointsOfSale: { ok: false, error: diagnosticsError?.message || "No pudimos consultar puntos de venta." }, + lastVoucher: { ok: false, error: diagnosticsError?.message || "No pudimos consultar el último comprobante." } + }); + } finally { + setArcaDiagnosticsLoading(false); + } + } catch (error: any) { + console.error("Error probando conexión ARCA:", error); + setArcaError(error?.message || "No pudimos validar la conexión con ARCA. Revisá el certificado y volvé a intentar."); + setCurrentStepIndex(1); + } finally { + setArcaLoading(false); + } + }; + + const renderArcaTestConnectionFlow = () => ( + + ); + + const renderArcaOnboardingFlow = () => ( + setCurrentStepIndex(1)} + handleFinish={handleActionSuccess} + /> + ); + const renderOnboardingOrgFlow = () => ( void; + legalName: string; + setLegalName: (value: string) => void; + taxCondition: ArcaTaxCondition; + setTaxCondition: (value: ArcaTaxCondition) => void; + pointOfSale: string; + setPointOfSale: (value: string) => void; + csrPem: string; + certificatePem: string; + setCertificatePem: (value: string) => void; + statusLabel: string; + arcaLoading: boolean; + arcaError: string | null; + handleNext: () => void; + handleSaveFiscalProfile: () => void; + handleGenerateCsr: () => void; + handleDownloadCsr: () => void; + handleCertificateFile: (file: File | null) => void; + handleUploadCertificate: () => void; + handleEditFiscalProfile: () => void; + handleFinish: () => void; +}; + +export default function ArcaOnboardingFlow(props: Props) { + const { currentStepIndex, cuit, setCuit, legalName, setLegalName, taxCondition, setTaxCondition, pointOfSale, setPointOfSale, csrPem, certificatePem, setCertificatePem, statusLabel, arcaLoading, arcaError, handleNext, handleSaveFiscalProfile, handleGenerateCsr, handleDownloadCsr, handleCertificateFile, handleUploadCertificate, handleEditFiscalProfile, handleFinish } = props; + const canSaveFiscalProfile = cuit.trim().length >= 11 && legalName.trim().length >= 3 && Number(pointOfSale) > 0; + const [csrCopyStatus, setCsrCopyStatus] = useState(null); + + const handleCopyCsr = async () => { + if (!csrPem) return; + + try { + await navigator.clipboard.writeText(csrPem); + setCsrCopyStatus("CSR copiado. Pegalo en ARCA cuando te lo pida."); + } catch (error) { + setCsrCopyStatus("No pudimos copiarlo automáticamente. Seleccioná el texto de abajo y copialo manualmente."); + } + }; + + return ( + <> + }> + Vas a necesitar tener acceso a ARCA con clave fiscal y permisos para administrar certificados del servicio de facturación electrónica. + + + +
+ setCuit(event.target.value.replace(/\D/g, ""))} placeholder="Sin guiones, por ejemplo 20123456789" inputMode="numeric" /> + setLegalName(event.target.value)} placeholder="Nombre fiscal o razón social" /> +
+ setTaxCondition("MONOTRIBUTO")}>Monotributo + setTaxCondition("EXENTO")}>Exento +
+ setPointOfSale(event.target.value.replace(/\D/g, ""))} placeholder="Ejemplo: 1" inputMode="numeric" /> + +
+
+ + +
+ {statusLabel || "Todavía no generamos el CSR para esta organización."} + {csrPem && Ya generamos el CSR. ARCA puede pedirte subir el archivo o pegar el contenido completo, incluyendo BEGIN y END CERTIFICATE REQUEST.} + {csrPem && ( +
+