feat: implement ARCA integration architecture including credential, fiscal profile, and invoice models, services, and onboarding flows

This commit is contained in:
2026-08-06 19:36:53 -03:00
parent 3b56d00fcb
commit a93681357f
28 changed files with 2935 additions and 39 deletions
+4
View File
@@ -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=
+4
View File
@@ -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=
+103 -36
View File
@@ -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",
+2
View File
@@ -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",
@@ -0,0 +1,83 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
ARCA_CREDENTIAL_STATUS,
CreateArcaCredentialParams,
FindArcaCredentialsParams,
IArcaCredential,
IArcaCredentialsAdapter,
UpdateArcaCredentialParams,
} from "./ArcaCredentials.Interface";
export interface IArcaCredentialDocument extends Omit<IArcaCredential, "id">, Document {}
export class ArcaCredentialsAdapterMongoose implements IArcaCredentialsAdapter {
schema: Schema;
arcaCredentialList: Model<IArcaCredentialDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
unique: true,
index: true,
},
csrPem: { type: String, required: false },
certificatePem: { type: String, required: false },
encryptedPrivateKey: { type: String, required: true },
privateKeyIv: { type: String, required: true },
privateKeyAuthTag: { type: String, required: true },
encryptionKeyVersion: { type: String, required: false, default: "v1" },
status: {
type: String,
required: true,
enum: Object.values(ARCA_CREDENTIAL_STATUS),
index: true,
},
certificateValidFrom: { type: Date, required: false },
certificateValidTo: { type: Date, required: false },
lastValidationError: { type: String, required: false },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.arcaCredentialList = model<IArcaCredentialDocument>("ArcaCredential", this.schema);
}
public async create(data: CreateArcaCredentialParams): Promise<IArcaCredential> {
return await this.arcaCredentialList.create(data);
}
private buildSearchCriteria(filters: FindArcaCredentialsParams): FilterQuery<IArcaCredential> {
const searchCriteria: FilterQuery<IArcaCredential> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.arcaCredentialList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.arcaCredentialList.findOne(searchCriteria).exec();
}
public async update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null> {
const { id, companyId, sessionUser, ...updateData } = data;
if (!id && !companyId) {
throw new Error("Se requiere un identificador para actualizar las credenciales ARCA");
}
return this.arcaCredentialList
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, { new: true })
.exec();
}
}
@@ -0,0 +1,159 @@
import { IArcaCredentialDocument } from "./ArcaCredentials.Adapter.Mongoose";
export enum ARCA_CREDENTIAL_STATUS {
CSR_GENERATED = "CSR_GENERATED",
CERTIFICATE_UPLOADED = "CERTIFICATE_UPLOADED",
READY = "READY",
INVALID = "INVALID",
DISABLED = "DISABLED",
}
export type GenerateArcaCredentialCsrParams = {
companyId: string;
sessionUser: string;
};
export type UploadArcaCredentialCertificateParams = {
companyId: string;
certificatePem: string;
sessionUser: string;
};
export type TestArcaWsaaLoginParams = {
companyId: string;
sessionUser: string;
};
export type ArcaWsaaLoginTestResult = {
status: "cached" | "created";
environment: "homologation";
service: "wsfe";
expirationTime: Date;
cached: boolean;
};
export type ArcaWsfeLastVoucherParams = {
companyId: string;
sessionUser: string;
};
export type ArcaWsfeLastVoucherResult = {
environment: "homologation";
service: "wsfe";
pointOfSale: number;
voucherType: 11;
lastVoucherNumber: number;
};
export type ArcaWsfeDiagnosticsParams = {
companyId: string;
sessionUser: string;
};
export type ArcaWsfeDiagnosticsResult = {
environment: "homologation";
service: "wsfe";
dummy: {
ok: boolean;
appServer?: string;
dbServer?: string;
authServer?: string;
error?: string;
};
voucherTypes: {
ok: boolean;
count?: number;
includesFacturaC?: boolean;
error?: string;
};
pointsOfSale: {
ok: boolean;
points?: number[];
includesConfiguredPointOfSale?: boolean;
configuredPointOfSale: number;
error?: string;
};
lastVoucher: {
ok: boolean;
pointOfSale: number;
voucherType: 11;
lastVoucherNumber?: number;
error?: string;
};
};
export type FindArcaCredentialsParams = {
id?: string;
companyId?: string;
status?: ARCA_CREDENTIAL_STATUS;
sessionUser?: string;
};
export type CreateArcaCredentialParams = {
companyId: string;
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey: string;
privateKeyIv: string;
privateKeyAuthTag: string;
encryptionKeyVersion?: string;
status: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
createdAt: Date;
updatedAt: Date;
};
export type UpdateArcaCredentialParams = FindArcaCredentialsParams & {
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey?: string;
privateKeyIv?: string;
privateKeyAuthTag?: string;
encryptionKeyVersion?: string;
status?: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
updatedAt: Date;
};
export interface IArcaCredential {
id?: string;
companyId: string;
csrPem?: string;
certificatePem?: string;
encryptedPrivateKey: string;
privateKeyIv: string;
privateKeyAuthTag: string;
encryptionKeyVersion?: string;
status: ARCA_CREDENTIAL_STATUS;
certificateValidFrom?: Date;
certificateValidTo?: Date;
lastValidationError?: string;
createdAt: Date;
updatedAt: Date;
}
export type ArcaCredentialView = Omit<
IArcaCredential,
"encryptedPrivateKey" | "privateKeyIv" | "privateKeyAuthTag"
>;
export interface IArcaCredentialsAdapter {
create(data: CreateArcaCredentialParams): Promise<IArcaCredential>;
find(filters: FindArcaCredentialsParams): Promise<IArcaCredential[]>;
findOne(filters: FindArcaCredentialsParams): Promise<IArcaCredentialDocument | null>;
update(data: UpdateArcaCredentialParams): Promise<IArcaCredentialDocument | null>;
}
export interface IArcaCredentialsManager {
arcaCredentials: IArcaCredentialsAdapter;
generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView>;
uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView>;
testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult>;
getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult>;
getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult>;
find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]>;
}
@@ -0,0 +1,796 @@
import crypto from "crypto";
import axios from "axios";
import forge from "node-forge";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
import {
ARCA_WSAA_ENVIRONMENT,
ARCA_WSAA_SERVICE,
} from "../ArcaWsaaTokens/ArcaWsaaTokens.Interface";
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
import {
IOrganizationFiscalProfile,
ORGANIZATION_FISCAL_PROFILE_STATUS,
} from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
import { ArcaCredentialsAdapterMongoose } from "./ArcaCredentials.Adapter.Mongoose";
import {
ARCA_CREDENTIAL_STATUS,
ArcaCredentialView,
ArcaWsfeDiagnosticsParams,
ArcaWsfeDiagnosticsResult,
ArcaWsfeLastVoucherParams,
ArcaWsfeLastVoucherResult,
FindArcaCredentialsParams,
GenerateArcaCredentialCsrParams,
ArcaWsaaLoginTestResult,
IArcaCredential,
IArcaCredentialsManager,
TestArcaWsaaLoginParams,
UploadArcaCredentialCertificateParams,
} from "./ArcaCredentials.Interface";
class ArcaCredentialsManager implements IArcaCredentialsManager {
arcaCredentials: ArcaCredentialsAdapterMongoose;
constructor() {
this.arcaCredentials = new ArcaCredentialsAdapterMongoose();
}
private getMasterKey(): Buffer {
const configuredKey = process.env.ARCA_CREDENTIALS_MASTER_KEY;
if (!configuredKey) {
throw new Error("ARCA_CREDENTIALS_MASTER_KEY no está configurada");
}
const key = /^[a-fA-F0-9]{64}$/.test(configuredKey)
? Buffer.from(configuredKey, "hex")
: Buffer.from(configuredKey, "base64");
if (key.length !== 32) {
throw new Error("ARCA_CREDENTIALS_MASTER_KEY debe tener 32 bytes en base64 o 64 caracteres hexadecimales");
}
return key;
}
private encryptPrivateKey(privateKeyPem: string): {
encryptedPrivateKey: string;
privateKeyIv: string;
privateKeyAuthTag: string;
} {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(
"aes-256-gcm",
crypto.createSecretKey(new Uint8Array(this.getMasterKey())),
new Uint8Array(iv)
);
const encryptedPrivateKey = cipher.update(privateKeyPem, "utf8", "base64") + cipher.final("base64");
return {
encryptedPrivateKey,
privateKeyIv: iv.toString("base64"),
privateKeyAuthTag: cipher.getAuthTag().toString("base64"),
};
}
private decryptPrivateKey(credential: IArcaCredential): string {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
crypto.createSecretKey(new Uint8Array(this.getMasterKey())),
new Uint8Array(Buffer.from(credential.privateKeyIv, "base64"))
);
decipher.setAuthTag(new Uint8Array(Buffer.from(credential.privateKeyAuthTag, "base64")));
return decipher.update(credential.encryptedPrivateKey, "base64", "utf8") + decipher.final("utf8");
}
private sanitizeCredential(credential: IArcaCredential): ArcaCredentialView {
const rawCredential = typeof (credential as any).toObject === "function"
? (credential as any).toObject()
: credential;
const { encryptedPrivateKey, privateKeyIv, privateKeyAuthTag, ...safeCredential } = rawCredential;
return safeCredential;
}
private async validateCompanyAdmin(companyId: string, sessionUser: string): Promise<void> {
await validateSessionUser({ sessionUser });
if (!companyId || !companyId.trim()) {
throw new Error("Se requiere una organización para operar credenciales ARCA");
}
await validatePermissionsByCompany({ companyId, sessionUser });
}
private async requireActiveFiscalProfile(
companyId: string,
sessionUser: string
): Promise<IOrganizationFiscalProfile> {
const profiles = await OrganizationFiscalProfiles.find({
companyId,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
sessionUser,
});
if (!profiles.length) {
throw new Error("Se requiere un perfil fiscal activo para generar credenciales ARCA");
}
return profiles[0];
}
private parseCertificate(certificatePem: string): forge.pki.Certificate {
try {
return forge.pki.certificateFromPem(certificatePem);
} catch (error) {
const errorOccurred = error as Error;
throw new Error(`El certificado ARCA no es válido: ${errorOccurred.message}`);
}
}
private certificateMatchesPrivateKey(certificate: forge.pki.Certificate, privateKeyPem: string): boolean {
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
const publicKeyFromPrivateKey = forge.pki.setRsaPublicKey(privateKey.n, privateKey.e);
return forge.pki.publicKeyToPem(certificate.publicKey) === forge.pki.publicKeyToPem(publicKeyFromPrivateKey);
}
private formatWsaaDate(date: Date): string {
// WSAA accepts ISO-8601 timestamps with an explicit offset; keep the Date instant in UTC.
return date.toISOString().replace(/\.\d{3}Z$/, "+00:00");
}
private buildTraXml(service: ARCA_WSAA_SERVICE): {
traXml: string;
generationTime: Date;
expirationTime: Date;
} {
const generationTime = new Date(Date.now() - 10 * 60 * 1000);
const expirationTime = new Date(Date.now() + 12 * 60 * 60 * 1000);
const uniqueId = Math.floor(Date.now() / 1000);
return {
generationTime,
expirationTime,
traXml: `<?xml version="1.0" encoding="UTF-8"?>\n<loginTicketRequest version="1.0">\n<header>\n<uniqueId>${uniqueId}</uniqueId>\n<generationTime>${this.formatWsaaDate(generationTime)}</generationTime>\n<expirationTime>${this.formatWsaaDate(expirationTime)}</expirationTime>\n</header>\n<service>${service}</service>\n</loginTicketRequest>`,
};
}
private signTraCms(traXml: string, certificatePem: string, privateKeyPem: string): string {
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(traXml, "utf8");
p7.addCertificate(certificatePem);
p7.addSigner({
key: privateKeyPem,
certificate: certificatePem,
digestAlgorithm: forge.pki.oids.sha256,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
{ type: forge.pki.oids.signingTime, value: new Date() as any },
],
});
p7.sign();
const der = forge.asn1.toDer(p7.toAsn1()).getBytes();
return Buffer.from(der, "binary").toString("base64");
}
private buildLoginCmsSoap(cms: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wsaa="http://wsaa.view.sua.dvadac.desein.afip.gov">\n<soapenv:Header/>\n<soapenv:Body>\n<wsaa:loginCms>\n<wsaa:in0>${cms}</wsaa:in0>\n</wsaa:loginCms>\n</soapenv:Body>\n</soapenv:Envelope>`;
}
private extractXmlValue(xml: string, tagName: string): string | undefined {
const match = xml.match(new RegExp(`<(?:\\w+:)?${tagName}[^>]*>([\\s\\S]*?)</(?:\\w+:)?${tagName}>`));
return match?.[1]?.trim();
}
private decodeXmlEntities(value: string): string {
return value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
private parseLoginCmsReturn(xml: string): {
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
} {
const loginCmsReturn = this.extractXmlValue(xml, "loginCmsReturn");
const responseXml = loginCmsReturn ? this.decodeXmlEntities(loginCmsReturn) : xml;
const token = this.extractXmlValue(responseXml, "token");
const sign = this.extractXmlValue(responseXml, "sign");
const expirationTime = this.extractXmlValue(responseXml, "expirationTime");
const generationTime = this.extractXmlValue(responseXml, "generationTime");
if (!token || !sign || !expirationTime) {
throw new Error("ARCA WSAA no devolvió token, firma o vencimiento válidos");
}
return {
token,
sign,
generationTime: generationTime ? new Date(generationTime) : undefined,
expirationTime: new Date(expirationTime),
};
}
private async getWsaaLoginTicket(companyId: string): Promise<{
token: string;
sign: string;
expirationTime: Date;
cached: boolean;
}> {
const service = ARCA_WSAA_SERVICE.WSFE;
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
const cachedToken = await ArcaWsaaTokens.getValidToken({ companyId, service, environment });
if (cachedToken) {
return {
token: cachedToken.token,
sign: cachedToken.sign,
expirationTime: cachedToken.expirationTime,
cached: true,
};
}
const credential = await this.arcaCredentials.findOne({
companyId,
status: ARCA_CREDENTIAL_STATUS.READY,
});
if (!credential?.certificatePem) {
throw new Error("Se requieren credenciales ARCA READY con certificado cargado");
}
const privateKeyPem = this.decryptPrivateKey(credential);
const { traXml } = this.buildTraXml(service);
const cms = this.signTraCms(traXml, credential.certificatePem, privateKeyPem);
const wsaaUrl = process.env.ARCA_WSAA_HOMOLOGATION_URL;
if (!wsaaUrl) {
throw new Error("ARCA_WSAA_HOMOLOGATION_URL no está configurada");
}
let response;
try {
response = await axios.post(wsaaUrl, this.buildLoginCmsSoap(cms), {
timeout: 30000,
headers: {
"Content-Type": "text/xml; charset=utf-8",
SOAPAction: "loginCms",
},
transformResponse: [(body) => body],
});
} catch (error) {
throw new Error(this.buildWsaaErrorMessage(error));
}
const wsaaToken = this.parseLoginCmsReturn(String(response.data));
const savedToken = await ArcaWsaaTokens.upsertToken({
companyId,
service,
environment,
...wsaaToken,
});
return {
token: savedToken.token,
sign: savedToken.sign,
expirationTime: savedToken.expirationTime,
cached: false,
};
}
private buildWsfeLastVoucherSoap(data: {
token: string;
sign: string;
cuit: string;
pointOfSale: number;
voucherType: 11;
}): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<FECompUltimoAutorizado xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
</Auth>
<PtoVta>${data.pointOfSale}</PtoVta>
<CbteTipo>${data.voucherType}</CbteTipo>
</FECompUltimoAutorizado>
</soapenv:Body>
</soapenv:Envelope>`;
}
private buildWsfeDummySoap(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<FEDummy xmlns="http://ar.gov.afip.dif.FEV1/" />
</soapenv:Body>
</soapenv:Envelope>`;
}
private buildWsfeParamGetTiposCbteSoap(data: { token: string; sign: string; cuit: string }): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<FEParamGetTiposCbte xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
</Auth>
</FEParamGetTiposCbte>
</soapenv:Body>
</soapenv:Envelope>`;
}
private buildWsfeParamGetPtosVentaSoap(data: { token: string; sign: string; cuit: string }): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header/>
<soapenv:Body>
<FEParamGetPtosVenta xmlns="http://ar.gov.afip.dif.FEV1/">
<Auth>
<Token>${data.token}</Token>
<Sign>${data.sign}</Sign>
<Cuit>${data.cuit}</Cuit>
</Auth>
</FEParamGetPtosVenta>
</soapenv:Body>
</soapenv:Envelope>`;
}
private async postWsfe(soapBody: string, soapAction: string): Promise<string> {
const wsfeUrl = process.env.ARCA_WSFE_HOMOLOGATION_URL;
if (!wsfeUrl) {
throw new Error("ARCA_WSFE_HOMOLOGATION_URL no está configurada");
}
const response = await axios.post(wsfeUrl, soapBody, {
timeout: 30000,
headers: {
"Content-Type": "text/xml; charset=utf-8",
SOAPAction: `"http://ar.gov.afip.dif.FEV1/${soapAction}"`,
},
transformResponse: [(body) => body],
});
return String(response.data);
}
private buildWsfeErrorMessage(error: unknown): string {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSFE";
}
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
if (decodedFault) {
return `ARCA WSFE rechazó la consulta: ${decodedFault}`;
}
const htmlTitle = responseBody ? this.extractHtmlTitle(responseBody) : undefined;
if (htmlTitle && error.response?.status) {
return `ARCA WSFE respondió con estado ${error.response.status}: ${htmlTitle}`;
}
if (error.code === "ECONNABORTED") {
return "ARCA WSFE no respondió a tiempo. Probá nuevamente en unos minutos.";
}
if (error.response?.status) {
if (responseBody) {
const compactBody = responseBody.replace(/\s+/g, " ").trim().slice(0, 500);
return `ARCA WSFE respondió con estado ${error.response.status}: ${compactBody}`;
}
return `ARCA WSFE respondió con estado ${error.response.status}`;
}
return error.message || "No pudimos conectar con ARCA WSFE";
}
private extractHtmlTitle(html: string): string | undefined {
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
return title ? this.decodeXmlEntities(title) : undefined;
}
private parseWsfeLastVoucherReturn(xml: string): number {
const faultString = this.extractXmlValue(xml, "faultstring");
if (faultString) {
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(faultString)}`);
}
const voucherNumber = this.extractXmlValue(xml, "CbteNro");
if (!voucherNumber) {
const errorMessage = this.extractXmlValue(xml, "Msg");
if (errorMessage) {
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(errorMessage)}`);
}
throw new Error("ARCA WSFE no devolvió el último comprobante autorizado");
}
const parsedVoucherNumber = Number(voucherNumber);
if (!Number.isFinite(parsedVoucherNumber)) {
throw new Error("ARCA WSFE devolvió un número de comprobante inválido");
}
return parsedVoucherNumber;
}
private parseWsfeDummyReturn(xml: string): ArcaWsfeDiagnosticsResult["dummy"] {
const errorMessage = this.extractWsfeResponseError(xml);
if (errorMessage) {
return { ok: false, error: errorMessage };
}
return {
ok: true,
appServer: this.extractXmlValue(xml, "AppServer"),
dbServer: this.extractXmlValue(xml, "DbServer"),
authServer: this.extractXmlValue(xml, "AuthServer"),
};
}
private parseWsfeVoucherTypesReturn(xml: string): ArcaWsfeDiagnosticsResult["voucherTypes"] {
const errorMessage = this.extractWsfeResponseError(xml);
if (errorMessage) {
return { ok: false, error: errorMessage };
}
const ids = Array.from(xml.matchAll(/<(?:\w+:)?Id>(\d+)<\/(?:\w+:)?Id>/g)).map((match) => Number(match[1]));
return {
ok: true,
count: ids.length,
includesFacturaC: ids.includes(11),
};
}
private parseWsfePointsOfSaleReturn(
xml: string,
configuredPointOfSale: number
): ArcaWsfeDiagnosticsResult["pointsOfSale"] {
const errorMessage = this.extractWsfeResponseError(xml);
if (errorMessage) {
return { ok: false, configuredPointOfSale, error: errorMessage };
}
const points = Array.from(xml.matchAll(/<(?:\w+:)?PtoVenta>(\d+)<\/(?:\w+:)?PtoVenta>/g)).map((match) => Number(match[1]));
return {
ok: true,
points,
includesConfiguredPointOfSale: points.includes(configuredPointOfSale),
configuredPointOfSale,
};
}
private extractWsfeResponseError(xml: string): string | undefined {
const faultString = this.extractXmlValue(xml, "faultstring");
const msg = this.extractXmlValue(xml, "Msg");
const errorMessage = faultString || msg;
return errorMessage ? this.decodeXmlEntities(errorMessage) : undefined;
}
private buildWsaaErrorMessage(error: unknown): string {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSAA";
}
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
if (decodedFault) {
return `ARCA WSAA rechazó la conexión: ${decodedFault}`;
}
if (error.code === "ECONNABORTED") {
return "ARCA WSAA no respondió a tiempo. Probá nuevamente en unos minutos.";
}
if (error.response?.status) {
return `ARCA WSAA respondió con estado ${error.response.status}`;
}
return error.message || "No pudimos conectar con ARCA WSAA";
}
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const keys = forge.pki.rsa.generateKeyPair(2048);
const csr = forge.pki.createCertificationRequest();
csr.publicKey = keys.publicKey;
csr.setSubject([
{ name: "commonName", value: fiscalProfile.cuit },
{ name: "organizationName", value: fiscalProfile.legalName },
{ name: "serialNumber", value: `CUIT ${fiscalProfile.cuit}` },
{ name: "countryName", value: "AR" },
]);
csr.sign(keys.privateKey, forge.md.sha256.create());
if (!csr.verify()) {
throw new Error("No se pudo verificar el CSR generado para ARCA");
}
const csrPem = forge.pki.certificationRequestToPem(csr);
const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey);
const encryptedPrivateKey = this.encryptPrivateKey(privateKeyPem);
const now = new Date();
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
const credentialData = {
companyId: data.companyId,
csrPem,
...encryptedPrivateKey,
encryptionKeyVersion: process.env.ARCA_CREDENTIALS_KEY_VERSION || "v1",
status: ARCA_CREDENTIAL_STATUS.CSR_GENERATED,
updatedAt: now,
};
const credential = existingCredential
? await this.arcaCredentials.update(credentialData)
: await this.arcaCredentials.create({
...credentialData,
createdAt: now,
});
if (!credential) {
throw new Error("No se pudieron guardar las credenciales ARCA");
}
return this.sanitizeCredential(credential);
}
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
if (!data.certificatePem || !data.certificatePem.trim()) {
throw new Error("Se requiere el certificado ARCA");
}
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
if (
!existingCredential ||
!existingCredential.encryptedPrivateKey ||
!existingCredential.privateKeyIv ||
!existingCredential.privateKeyAuthTag
) {
throw new Error("Primero se deben generar las credenciales ARCA para la organización");
}
const certificatePem = data.certificatePem.trim();
let certificate: forge.pki.Certificate;
let privateKeyPem: string;
try {
certificate = this.parseCertificate(certificatePem);
privateKeyPem = this.decryptPrivateKey(existingCredential);
if (!this.certificateMatchesPrivateKey(certificate, privateKeyPem)) {
throw new Error("El certificado ARCA no corresponde a la clave privada generada para esta organización");
}
} catch (error) {
const errorOccurred = error as Error;
await this.arcaCredentials.update({
companyId: data.companyId,
lastValidationError: errorOccurred.message,
status: ARCA_CREDENTIAL_STATUS.INVALID,
updatedAt: new Date(),
});
throw errorOccurred;
}
const updatedCredential = await this.arcaCredentials.update({
companyId: data.companyId,
certificatePem,
certificateValidFrom: certificate.validity.notBefore,
certificateValidTo: certificate.validity.notAfter,
lastValidationError: undefined,
status: ARCA_CREDENTIAL_STATUS.READY,
updatedAt: new Date(),
});
if (!updatedCredential) {
throw new Error("No se pudieron actualizar las credenciales ARCA");
}
return this.sanitizeCredential(updatedCredential);
}
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const service = ARCA_WSAA_SERVICE.WSFE;
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
return {
status: loginTicket.cached ? "cached" : "created",
environment,
service,
expirationTime: loginTicket.expirationTime,
cached: loginTicket.cached,
};
}
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const voucherType = 11;
let responseXml: string;
try {
responseXml = await this.postWsfe(
this.buildWsfeLastVoucherSoap({
token: loginTicket.token,
sign: loginTicket.sign,
cuit: fiscalProfile.cuit,
pointOfSale: fiscalProfile.pointOfSale,
voucherType,
}),
"FECompUltimoAutorizado"
);
} catch (error) {
throw new Error(this.buildWsfeErrorMessage(error));
}
return {
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
service: ARCA_WSAA_SERVICE.WSFE,
pointOfSale: fiscalProfile.pointOfSale,
voucherType,
lastVoucherNumber: this.parseWsfeLastVoucherReturn(responseXml),
};
}
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const voucherType = 11;
const diagnostics: ArcaWsfeDiagnosticsResult = {
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
service: ARCA_WSAA_SERVICE.WSFE,
dummy: { ok: false },
voucherTypes: { ok: false },
pointsOfSale: {
ok: false,
configuredPointOfSale: fiscalProfile.pointOfSale,
},
lastVoucher: {
ok: false,
pointOfSale: fiscalProfile.pointOfSale,
voucherType,
},
};
try {
diagnostics.dummy = this.parseWsfeDummyReturn(
await this.postWsfe(this.buildWsfeDummySoap(), "FEDummy")
);
} catch (error) {
diagnostics.dummy = { ok: false, error: this.buildWsfeErrorMessage(error) };
}
try {
diagnostics.voucherTypes = this.parseWsfeVoucherTypesReturn(
await this.postWsfe(
this.buildWsfeParamGetTiposCbteSoap({
token: loginTicket.token,
sign: loginTicket.sign,
cuit: fiscalProfile.cuit,
}),
"FEParamGetTiposCbte"
)
);
} catch (error) {
diagnostics.voucherTypes = { ok: false, error: this.buildWsfeErrorMessage(error) };
}
try {
diagnostics.pointsOfSale = this.parseWsfePointsOfSaleReturn(
await this.postWsfe(
this.buildWsfeParamGetPtosVentaSoap({
token: loginTicket.token,
sign: loginTicket.sign,
cuit: fiscalProfile.cuit,
}),
"FEParamGetPtosVenta"
),
fiscalProfile.pointOfSale
);
} catch (error) {
diagnostics.pointsOfSale = {
ok: false,
configuredPointOfSale: fiscalProfile.pointOfSale,
error: this.buildWsfeErrorMessage(error),
};
}
try {
diagnostics.lastVoucher.lastVoucherNumber = this.parseWsfeLastVoucherReturn(
await this.postWsfe(
this.buildWsfeLastVoucherSoap({
token: loginTicket.token,
sign: loginTicket.sign,
cuit: fiscalProfile.cuit,
pointOfSale: fiscalProfile.pointOfSale,
voucherType,
}),
"FECompUltimoAutorizado"
)
);
diagnostics.lastVoucher.ok = true;
} catch (error) {
diagnostics.lastVoucher.error = this.buildWsfeErrorMessage(error);
}
return diagnostics;
}
public async find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]> {
if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado");
}
await validateSessionUser({ sessionUser: data.sessionUser });
if (data.companyId) {
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
} else if (data.id) {
const credential = await this.arcaCredentials.findOne(data);
if (!credential) {
return [];
}
await validatePermissionsByCompany({
companyId: String(credential.companyId),
sessionUser: data.sessionUser,
});
return [this.sanitizeCredential(credential)];
} else {
throw new Error("Se requiere una organización para buscar credenciales ARCA");
}
const credentials = await this.arcaCredentials.find(data);
return credentials.map((credential) => this.sanitizeCredential(credential));
}
}
export default new ArcaCredentialsManager();
@@ -0,0 +1,97 @@
import { Document, FilterQuery, Model, Schema, model, models } from "mongoose";
import {
ARCA_WSAA_ENVIRONMENT,
ARCA_WSAA_SERVICE,
ArcaWsaaTokenLookupParams,
CreateArcaWsaaTokenParams,
IArcaWsaaToken,
IArcaWsaaTokensAdapter,
UpdateArcaWsaaTokenParams,
} from "./ArcaWsaaTokens.Interface";
export interface IArcaWsaaTokenDocument extends Omit<IArcaWsaaToken, "id">, Document {}
export class ArcaWsaaTokensAdapterMongoose implements IArcaWsaaTokensAdapter {
schema: Schema;
arcaWsaaTokenList: Model<IArcaWsaaTokenDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
index: true,
},
service: {
type: String,
required: true,
enum: Object.values(ARCA_WSAA_SERVICE),
index: true,
},
environment: {
type: String,
required: true,
enum: Object.values(ARCA_WSAA_ENVIRONMENT),
index: true,
},
token: { type: String, required: true },
sign: { type: String, required: true },
generationTime: { type: Date, required: false },
expirationTime: { type: Date, required: true, index: true },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.schema.index({ companyId: 1, service: 1, environment: 1 }, { unique: true });
this.arcaWsaaTokenList =
models.ArcaWsaaToken || model<IArcaWsaaTokenDocument>("ArcaWsaaToken", this.schema);
}
public async create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
return await this.arcaWsaaTokenList.create(data);
}
private buildSearchCriteria(
filters: Partial<ArcaWsaaTokenLookupParams>
): FilterQuery<IArcaWsaaToken> {
const searchCriteria: FilterQuery<IArcaWsaaToken> = {};
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.service) searchCriteria.service = filters.service;
if (filters.environment) searchCriteria.environment = filters.environment;
return searchCriteria;
}
public async find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]> {
return this.arcaWsaaTokenList.find(this.buildSearchCriteria(filters)).sort({ createdAt: -1 }).exec();
}
public async findOne(
filters: Partial<ArcaWsaaTokenLookupParams>
): Promise<IArcaWsaaTokenDocument | null> {
return this.arcaWsaaTokenList.findOne(this.buildSearchCriteria(filters)).exec();
}
public async update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
const { companyId, service, environment, ...updateData } = data;
return this.arcaWsaaTokenList
.findOneAndUpdate(this.buildSearchCriteria({ companyId, service, environment }), updateData, {
new: true,
})
.exec();
}
public async upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
const { companyId, service, environment, createdAt, ...updateData } = data;
return this.arcaWsaaTokenList
.findOneAndUpdate(
this.buildSearchCriteria({ companyId, service, environment }),
{ $set: updateData, $setOnInsert: { companyId, service, environment, createdAt } },
{ new: true, upsert: true }
)
.exec();
}
}
@@ -0,0 +1,67 @@
import { IArcaWsaaTokenDocument } from "./ArcaWsaaTokens.Adapter.Mongoose";
export enum ARCA_WSAA_SERVICE {
WSFE = "wsfe",
}
export enum ARCA_WSAA_ENVIRONMENT {
HOMOLOGATION = "homologation",
}
export type ArcaWsaaTokenLookupParams = {
companyId: string;
service: ARCA_WSAA_SERVICE;
environment: ARCA_WSAA_ENVIRONMENT;
};
export type CreateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
createdAt: Date;
updatedAt: Date;
};
export type UpdateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token?: string;
sign?: string;
generationTime?: Date;
expirationTime?: Date;
updatedAt: Date;
};
export type UpsertArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
};
export interface IArcaWsaaToken {
id?: string;
companyId: string;
service: ARCA_WSAA_SERVICE;
environment: ARCA_WSAA_ENVIRONMENT;
token: string;
sign: string;
generationTime?: Date;
expirationTime: Date;
createdAt: Date;
updatedAt: Date;
}
export interface IArcaWsaaTokensAdapter {
create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]>;
findOne(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaTokenDocument | null>;
update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
}
export interface IArcaWsaaTokensManager {
arcaWsaaTokens: IArcaWsaaTokensAdapter;
getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null>;
saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
}
@@ -0,0 +1,58 @@
import { ArcaWsaaTokensAdapterMongoose } from "./ArcaWsaaTokens.Adapter.Mongoose";
import {
ArcaWsaaTokenLookupParams,
IArcaWsaaToken,
IArcaWsaaTokensManager,
UpsertArcaWsaaTokenParams,
} from "./ArcaWsaaTokens.Interface";
const TOKEN_REUSE_SKEW_MS = 10 * 60 * 1000;
class ArcaWsaaTokensManager implements IArcaWsaaTokensManager {
arcaWsaaTokens: ArcaWsaaTokensAdapterMongoose;
constructor() {
this.arcaWsaaTokens = new ArcaWsaaTokensAdapterMongoose();
}
public async getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null> {
const existingToken = await this.arcaWsaaTokens.findOne(data);
if (!existingToken) {
return null;
}
const reuseUntil = Date.now() + TOKEN_REUSE_SKEW_MS;
if (new Date(existingToken.expirationTime).getTime() <= reuseUntil) {
return null;
}
return existingToken;
}
public async saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
const now = new Date();
return await this.arcaWsaaTokens.create({
...data,
createdAt: now,
updatedAt: now,
});
}
public async upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
const now = new Date();
const token = await this.arcaWsaaTokens.upsert({
...data,
createdAt: now,
updatedAt: now,
});
if (!token) {
throw new Error("No se pudo guardar el token WSAA de ARCA");
}
return token;
}
}
export default new ArcaWsaaTokensManager();
@@ -0,0 +1,82 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
CreateInvoiceParams,
FindInvoicesParams,
IInvoice,
IInvoicesAdapter,
INVOICE_STATUS,
INVOICE_TYPE,
} from "./Invoices.Interface";
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
export class InvoicesAdapterMongoose implements IInvoicesAdapter {
schema: Schema;
invoiceList: Model<IInvoiceDocument>;
constructor() {
this.schema = new Schema({
cashMovementId: {
type: Schema.Types.ObjectId,
required: true,
ref: "CashFlow",
unique: true,
index: true,
},
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
index: true,
},
clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" },
paymentId: { type: Schema.Types.ObjectId, required: false, ref: "Payment" },
clientAccountMovementId: {
type: Schema.Types.ObjectId,
required: false,
ref: "ClientAccountMovement",
},
appointmentId: { type: Schema.Types.ObjectId, required: false, ref: "Appointment" },
amount: { type: Number, required: true },
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true },
cae: { type: String, required: false },
caeExpiresAt: { type: Date, required: false },
pointOfSale: { type: Number, required: false },
voucherNumber: { type: Number, required: false },
voucherType: { type: String, required: false },
arcaErrorCode: { type: String, required: false },
arcaErrorMessage: { type: String, required: false },
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.invoiceList = model<IInvoiceDocument>("Invoice", this.schema);
}
public async create(data: CreateInvoiceParams): Promise<IInvoice> {
return await this.invoiceList.create(data);
}
private buildSearchCriteria(filters: FindInvoicesParams): FilterQuery<IInvoice> {
const searchCriteria: FilterQuery<IInvoice> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId;
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(filters: FindInvoicesParams): Promise<IInvoice[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.invoiceList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.invoiceList.findOne(searchCriteria).exec();
}
}
@@ -0,0 +1,81 @@
import { IInvoiceDocument } from "./Invoices.Adapter.Mongoose";
export enum INVOICE_TYPE {
FACTURA_C = "FACTURA_C",
}
export enum INVOICE_STATUS {
PENDING_ARCA = "PENDING_ARCA",
APPROVED = "APPROVED",
PENDING_VERIFICATION = "PENDING_VERIFICATION",
REJECTED = "REJECTED",
FAILED = "FAILED",
}
export type CreateInvoiceFromCashMovementParams = {
cashMovementId: string;
sessionUser: string;
};
export type FindInvoicesParams = {
id?: string;
cashMovementId?: string;
companyId?: string;
status?: INVOICE_STATUS;
sessionUser?: string;
};
export type CreateInvoiceParams = {
cashMovementId: string;
companyId: string;
clientId?: string;
paymentId?: string;
clientAccountMovementId?: string;
appointmentId?: string;
amount: number;
type: INVOICE_TYPE;
status: INVOICE_STATUS;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
voucherNumber?: number;
voucherType?: string;
arcaErrorCode?: string;
arcaErrorMessage?: string;
createdAt: Date;
updatedAt: Date;
};
export interface IInvoice {
id?: string;
cashMovementId: string;
companyId: string;
clientId?: string;
paymentId?: string;
clientAccountMovementId?: string;
appointmentId?: string;
amount: number;
type: INVOICE_TYPE;
status: INVOICE_STATUS;
cae?: string;
caeExpiresAt?: Date;
pointOfSale?: number;
voucherNumber?: number;
voucherType?: string;
arcaErrorCode?: string;
arcaErrorMessage?: string;
createdAt: Date;
updatedAt: Date;
}
export interface IInvoicesAdapter {
create(data: CreateInvoiceParams): Promise<IInvoice>;
find(filters: FindInvoicesParams): Promise<IInvoice[]>;
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
}
export interface IInvoicesManager {
invoices: IInvoicesAdapter;
createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice>;
find(data: FindInvoicesParams): Promise<IInvoice[]>;
}
+132
View File
@@ -0,0 +1,132 @@
import CashFlow from "../CashFlow/CashFlow";
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
import {
CreateInvoiceFromCashMovementParams,
FindInvoicesParams,
IInvoice,
IInvoicesManager,
INVOICE_STATUS,
INVOICE_TYPE,
} from "./Invoices.Interface";
class InvoicesManager implements IInvoicesManager {
invoices: InvoicesAdapterMongoose;
constructor() {
this.invoices = new InvoicesAdapterMongoose();
}
private isDuplicateKeyError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
}
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
await validateSessionUser({ sessionUser: data.sessionUser });
if (!data.cashMovementId || !data.cashMovementId.trim()) {
throw new Error("Se requiere un movimiento de caja para emitir la factura");
}
const movement = await CashFlow.movements.movementList.findOne({
_id: data.cashMovementId,
});
if (!movement) {
throw new Error("No se ha encontrado el movimiento de caja");
}
const companyId = String(movement.companyId);
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
throw new Error("El movimiento de caja no es facturable");
}
const fiscalProfiles = await OrganizationFiscalProfiles.find({
companyId,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
sessionUser: data.sessionUser,
});
if (fiscalProfiles.length === 0) {
throw new Error("La organización debe tener un perfil fiscal activo para emitir facturas");
}
const existingInvoice = await this.invoices.findOne({
cashMovementId: data.cashMovementId,
});
if (existingInvoice) {
return existingInvoice;
}
try {
return await this.invoices.create({
cashMovementId: data.cashMovementId,
companyId,
clientId: movement.clientId ? String(movement.clientId) : undefined,
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
clientAccountMovementId: movement.clientAccountMovementId
? String(movement.clientAccountMovementId)
: undefined,
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
amount: movement.amount,
type: INVOICE_TYPE.FACTURA_C,
status: INVOICE_STATUS.PENDING_ARCA,
createdAt: new Date(),
updatedAt: new Date(),
});
} catch (error) {
if (this.isDuplicateKeyError(error)) {
const duplicateInvoice = await this.invoices.findOne({
cashMovementId: data.cashMovementId,
});
if (duplicateInvoice) {
return duplicateInvoice;
}
}
throw error;
}
}
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado");
}
const sessionUser = data.sessionUser;
await validateSessionUser({ sessionUser });
if (data.companyId) {
await validatePermissionsByCompany({
companyId: data.companyId,
sessionUser,
});
} else if (data.id || data.cashMovementId) {
const invoice = await this.invoices.findOne(data);
if (!invoice) {
return [];
}
await validatePermissionsByCompany({
companyId: String(invoice.companyId),
sessionUser,
});
return [invoice];
} else {
throw new Error("Se requiere una organización para buscar facturas");
}
return await this.invoices.find(data);
}
}
export default new InvoicesManager();
@@ -0,0 +1,100 @@
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
import {
CreateOrganizationFiscalProfileParams,
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
IOrganizationFiscalProfilesAdapter,
ORGANIZATION_FISCAL_PROFILE_STATUS,
ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION,
UpdateOrganizationFiscalProfileParams,
} from "./OrganizationFiscalProfiles.Interface";
export interface IOrganizationFiscalProfileDocument
extends Omit<IOrganizationFiscalProfile, "id">,
Document {}
export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationFiscalProfilesAdapter {
schema: Schema;
organizationFiscalProfileList: Model<IOrganizationFiscalProfileDocument>;
constructor() {
this.schema = new Schema({
companyId: {
type: Schema.Types.ObjectId,
required: true,
ref: "Company",
unique: true,
index: true,
},
cuit: { type: String, required: true },
legalName: { type: String, required: true },
taxCondition: {
type: String,
required: true,
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION),
},
pointOfSale: { type: Number, required: true },
status: {
type: String,
required: true,
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_STATUS),
index: true,
},
createdAt: { type: Date, required: true, default: Date.now },
updatedAt: { type: Date, required: true, default: Date.now },
});
this.organizationFiscalProfileList = model<IOrganizationFiscalProfileDocument>(
"OrganizationFiscalProfile",
this.schema
);
}
public async create(
data: CreateOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile> {
return await this.organizationFiscalProfileList.create(data);
}
private buildSearchCriteria(
filters: FindOrganizationFiscalProfilesParams
): FilterQuery<IOrganizationFiscalProfile> {
const searchCriteria: FilterQuery<IOrganizationFiscalProfile> = {};
if (filters.id) searchCriteria._id = filters.id;
if (filters.companyId) searchCriteria.companyId = filters.companyId;
if (filters.status) searchCriteria.status = filters.status;
return searchCriteria;
}
public async find(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.organizationFiscalProfileList.find(searchCriteria).sort({ createdAt: -1 }).exec();
}
public async findOne(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfileDocument | null> {
const searchCriteria = this.buildSearchCriteria(filters);
return this.organizationFiscalProfileList.findOne(searchCriteria).exec();
}
public async update(
data: UpdateOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfileDocument | null> {
const { id, companyId, sessionUser, ...updateData } = data;
if (!id && !companyId) {
throw new Error("Se requiere un identificador para actualizar el perfil fiscal");
}
return this.organizationFiscalProfileList
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, {
new: true,
})
.exec();
}
}
@@ -0,0 +1,75 @@
import { IOrganizationFiscalProfileDocument } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
export enum ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION {
MONOTRIBUTO = "MONOTRIBUTO",
EXENTO = "EXENTO",
}
export enum ORGANIZATION_FISCAL_PROFILE_STATUS {
PENDING = "PENDING",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
export type UpsertOrganizationFiscalProfileParams = {
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
sessionUser: string;
};
export type FindOrganizationFiscalProfilesParams = {
id?: string;
companyId?: string;
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
sessionUser?: string;
};
export type CreateOrganizationFiscalProfileParams = {
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
createdAt: Date;
updatedAt: Date;
};
export type UpdateOrganizationFiscalProfileParams = FindOrganizationFiscalProfilesParams & {
cuit?: string;
legalName?: string;
taxCondition?: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale?: number;
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
updatedAt: Date;
};
export interface IOrganizationFiscalProfile {
id?: string;
companyId: string;
cuit: string;
legalName: string;
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
pointOfSale: number;
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
createdAt: Date;
updatedAt: Date;
}
export interface IOrganizationFiscalProfilesAdapter {
create(data: CreateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
find(filters: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
findOne(
filters: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfileDocument | null>;
update(data: UpdateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfileDocument | null>;
}
export interface IOrganizationFiscalProfilesManager {
organizationFiscalProfiles: IOrganizationFiscalProfilesAdapter;
upsert(data: UpsertOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
find(data: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
}
@@ -0,0 +1,149 @@
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
import {
FindOrganizationFiscalProfilesParams,
IOrganizationFiscalProfile,
IOrganizationFiscalProfilesManager,
ORGANIZATION_FISCAL_PROFILE_STATUS,
UpsertOrganizationFiscalProfileParams,
} from "./OrganizationFiscalProfiles.Interface";
class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesManager {
organizationFiscalProfiles: OrganizationFiscalProfilesAdapterMongoose;
constructor() {
this.organizationFiscalProfiles = new OrganizationFiscalProfilesAdapterMongoose();
}
private isDuplicateKeyError(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
}
private validateProfileFields(data: UpsertOrganizationFiscalProfileParams): void {
if (!/^\d{11}$/.test(data.cuit)) {
throw new Error("El CUIT debe contener 11 dígitos");
}
if (!data.legalName || !data.legalName.trim()) {
throw new Error("La razón social es requerida");
}
if (!data.pointOfSale || data.pointOfSale <= 0) {
throw new Error("El punto de venta debe ser mayor a 0");
}
}
public async upsert(
data: UpsertOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile> {
await validateSessionUser({ sessionUser: data.sessionUser });
if (!data.companyId || !data.companyId.trim()) {
throw new Error("Se requiere una organización para guardar el perfil fiscal");
}
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
this.validateProfileFields(data);
const existingProfile = await this.organizationFiscalProfiles.findOne({
companyId: data.companyId,
});
const now = new Date();
if (existingProfile) {
const updatedProfile = await this.organizationFiscalProfiles.update({
companyId: data.companyId,
cuit: data.cuit,
legalName: data.legalName.trim(),
taxCondition: data.taxCondition,
pointOfSale: data.pointOfSale,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
updatedAt: now,
});
if (!updatedProfile) {
throw new Error("No se pudo actualizar el perfil fiscal");
}
return updatedProfile;
}
try {
return await this.organizationFiscalProfiles.create({
companyId: data.companyId,
cuit: data.cuit,
legalName: data.legalName.trim(),
taxCondition: data.taxCondition,
pointOfSale: data.pointOfSale,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
createdAt: now,
updatedAt: now,
});
} catch (error) {
if (this.isDuplicateKeyError(error)) {
const updatedProfile = await this.organizationFiscalProfiles.update({
companyId: data.companyId,
cuit: data.cuit,
legalName: data.legalName.trim(),
taxCondition: data.taxCondition,
pointOfSale: data.pointOfSale,
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
updatedAt: new Date(),
});
if (updatedProfile) {
return updatedProfile;
}
const existingProfile = await this.organizationFiscalProfiles.findOne({
companyId: data.companyId,
});
if (existingProfile) {
return existingProfile;
}
}
throw error;
}
}
public async find(
data: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
if (!data.sessionUser) {
throw new Error("Se requiere un usuario autenticado");
}
const sessionUser = data.sessionUser;
await validateSessionUser({ sessionUser });
if (data.companyId) {
await validatePermissionsByCompany({
companyId: data.companyId,
sessionUser,
});
} else if (data.id) {
const profile = await this.organizationFiscalProfiles.findOne(data);
if (!profile) {
return [];
}
await validatePermissionsByCompany({
companyId: String(profile.companyId),
sessionUser,
});
return [profile];
} else {
throw new Error("Se requiere una organización para buscar perfiles fiscales");
}
return await this.organizationFiscalProfiles.find(data);
}
}
export default new OrganizationFiscalProfilesManager();
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async generateCsr(
@Body() requestBody: GenerateArcaCredentialCsrParams
): Promise<ArcaCredentialView | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async uploadCertificate(
@Body() requestBody: UploadArcaCredentialCertificateParams
): Promise<ArcaCredentialView | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async testWsaaLogin(
@Body() requestBody: TestArcaWsaaLoginParams
): Promise<ArcaWsaaLoginTestResult | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async getWsfeLastVoucher(
@Body() requestBody: ArcaWsfeLastVoucherParams
): Promise<ArcaWsfeLastVoucherResult | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async getWsfeDiagnostics(
@Body() requestBody: ArcaWsfeDiagnosticsParams
): Promise<ArcaWsfeDiagnosticsResult | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(
@Body() requestBody: FindArcaCredentialsParams
): Promise<ArcaCredentialView[] | ApiValidationError> {
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);
}
}
}
@@ -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<ArcaCredentialView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.generateCsr(data);
}
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.uploadCertificate(data);
}
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.testWsaaLogin(data);
}
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.getWsfeLastVoucher(data);
}
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.getWsfeDiagnostics(data);
}
public async find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ArcaCredentials.find(data);
}
}
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async createFromCashMovement(
@Body() requestBody: CreateInvoiceFromCashMovementParams
): Promise<IInvoice | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(@Body() requestBody: FindInvoicesParams): Promise<IInvoice[] | ApiValidationError> {
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);
}
}
}
@@ -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<IInvoice> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await Invoices.createFromCashMovement(data);
}
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await Invoices.find(data);
}
}
@@ -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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async upsert(
@Body() requestBody: UpsertOrganizationFiscalProfileParams
): Promise<IOrganizationFiscalProfile | ApiValidationError> {
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<ApiValidationError>(500, "Ha ocurrido un error")
@Post()
public async find(
@Body() requestBody: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[] | ApiValidationError> {
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);
}
}
}
@@ -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<IOrganizationFiscalProfile> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await OrganizationFiscalProfiles.upsert(data);
}
public async find(
data: FindOrganizationFiscalProfilesParams
): Promise<IOrganizationFiscalProfile[]> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await OrganizationFiscalProfiles.find(data);
}
}
@@ -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<number> => {
return ApiRequest.post<number>("cashflow/paginate-balance", data);
};
export const getArcaWsfeLastVoucher = async (
data: ArcaWsfeLastVoucherParams
): Promise<ArcaWsfeLastVoucherResult> => {
return ApiRequest.post<ArcaWsfeLastVoucherResult>("arca-credentials/wsfe-last-voucher", data);
};
@@ -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>(dayjs().startOf("day"));
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
const [view, setView] = useState<PaginateCashFlowResults | null>(null);
const [arcaLastVoucher, setArcaLastVoucher] = useState<ArcaWsfeLastVoucherResult | null>(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)}
</div>
<div
style={{
marginBottom: "10px",
padding: "8px 12px",
borderRadius: "8px",
backgroundColor: "var(--white)",
color: arcaLastVoucherError
? "var(--gray-dark)"
: "var(--wine-dark)",
fontSize: "13px",
textAlign: "center",
}}
>
{arcaLastVoucherLoading ? (
"Consultando último comprobante ARCA..."
) : arcaLastVoucher ? (
<>
Última Factura C autorizada ARCA: #{" "}
<strong>{arcaLastVoucher.lastVoucherNumber}</strong>
{" · "}PV {arcaLastVoucher.pointOfSale}
</>
) : arcaLastVoucherError ? (
<>
No pudimos consultar ARCA
{arcaLastVoucherErrorMessage && (
<div style={{ marginTop: "4px" }}>
{arcaLastVoucherErrorMessage}
</div>
)}
</>
) : (
"ARCA: esperando consulta del último comprobante"
)}
</div>
<div
style={{
display: "flex",
@@ -19,6 +19,7 @@ import ReviewsIcon from '@mui/icons-material/Reviews';
import NotificationsIcon from '@mui/icons-material/Notifications';
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import API from "@services/Api.Service";
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
@@ -88,6 +89,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
keywords: ["link", "enlace", "url", "slug", "qr", "codigo qr", "publico", "reservas"],
icon: QrCode2Icon
},
{
id: "arca-onboarding",
flowId: "arca-onboarding",
title: "Conectar ARCA",
description: "Configurá los datos fiscales y el certificado para emitir Factura C.",
category: "Integraciones",
keywords: ["arca", "afip", "factura", "factura c", "fiscal", "certificado", "csr", "monotributo"],
icon: ReceiptLongIcon
},
{
id: "arca-test-connection",
flowId: "arca-test-connection",
title: "Probar conexión ARCA",
description: "Validá el certificado y la conexión con el servicio WSFE de ARCA.",
category: "Integraciones",
keywords: ["arca", "afip", "wsaa", "wsfe", "conexion", "certificado", "factura"],
icon: ReceiptLongIcon
},
{
id: "notifications",
flowId: "notifications",
@@ -27,6 +27,8 @@ import NotificationsFlow from "./flows/NotificationsFlow";
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
import ReservationPeriodFlow from "./flows/ReservationPeriodFlow";
import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
import SuccessScreen from "./components/SuccessScreen";
import OrganizationPicker from "./components/OrganizationPicker";
import { reservationPeriodOptions } from "./constants/reservationPeriods";
@@ -178,6 +180,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [wapQr, setWapQr] = useState<string | null>(null);
const [wapError, setWapError] = useState<string | null>(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<string | null>(null);
const [arcaTestResult, setArcaTestResult] = useState<ArcaWsaaTestResult | null>(null);
const [arcaDiagnosticsResult, setArcaDiagnosticsResult] = useState<ArcaWsfeDiagnosticsResult | null>(null);
// State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(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<any[]>("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<any[]>("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<any[]>("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<any>("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<any>("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<any>("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<ArcaWsaaTestResult>("arca-credentials/test-wsaa-login", {
companyId: createdCompanyId,
sessionUser: SessionInfo.userId
});
setArcaTestResult(result);
setCurrentStepIndex(1);
setArcaDiagnosticsLoading(true);
try {
const diagnostics = await API.post<ArcaWsfeDiagnosticsResult>("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 = () => (
<ArcaTestConnectionFlow
currentStepIndex={currentStepIndex}
loading={arcaLoading}
diagnosticsLoading={arcaDiagnosticsLoading}
error={arcaError}
result={arcaTestResult}
diagnostics={arcaDiagnosticsResult}
handleTestConnection={handleTestArcaConnection}
handleFinish={handleActionSuccess}
/>
);
const renderArcaOnboardingFlow = () => (
<ArcaOnboardingFlow
currentStepIndex={currentStepIndex}
cuit={arcaCuit}
setCuit={setArcaCuit}
legalName={arcaLegalName}
setLegalName={setArcaLegalName}
taxCondition={arcaTaxCondition}
setTaxCondition={setArcaTaxCondition}
pointOfSale={arcaPointOfSale}
setPointOfSale={setArcaPointOfSale}
csrPem={arcaCsrPem}
certificatePem={arcaCertificatePem}
setCertificatePem={setArcaCertificatePem}
statusLabel={arcaStatusLabel}
arcaLoading={arcaLoading}
arcaError={arcaError}
handleNext={handleNext}
handleSaveFiscalProfile={handleSaveArcaFiscalProfile}
handleGenerateCsr={handleGenerateArcaCsr}
handleDownloadCsr={handleDownloadArcaCsr}
handleCertificateFile={handleArcaCertificateFile}
handleUploadCertificate={handleUploadArcaCertificate}
handleEditFiscalProfile={() => setCurrentStepIndex(1)}
handleFinish={handleActionSuccess}
/>
);
const renderOnboardingOrgFlow = () => (
<OnboardingOrgFlow
currentStepIndex={currentStepIndex}
@@ -1678,6 +1905,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
if (flowId === "public-link") return renderPublicLinkFlow();
if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow();
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow();
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
@@ -0,0 +1,132 @@
import React, { useState } from "react";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import QuestionCard from "../../QuestionCard/QuestionCard";
import QuestionInput from "../../QuestionCard/QuestionInput";
type ArcaTaxCondition = "MONOTRIBUTO" | "EXENTO";
type Props = {
currentStepIndex: number;
cuit: string;
setCuit: (value: string) => 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<string | null>(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 (
<>
<QuestionCard isActive={currentStepIndex === 0} title="Conectar ARCA" description="Te voy a guiar para dejar lista la emisión de Factura C. Primero guardamos tus datos fiscales, después generamos el pedido de certificado y al final cargás el certificado que te entrega ARCA." onNext={handleNext} nextLabel="Empezar" topAccessory={<ReceiptLongIcon style={{ fontSize: 52, color: "var(--wine-red)" }} />}>
<InfoPanel>Vas a necesitar tener acceso a ARCA con clave fiscal y permisos para administrar certificados del servicio de facturación electrónica.</InfoPanel>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 1} title="Datos fiscales" description="Completá los datos que van a identificar a tu organización cuando emitas Factura C." onNext={handleSaveFiscalProfile} disableNext={!canSaveFiscalProfile || arcaLoading} nextLabel={arcaLoading ? "Guardando..." : "Guardar datos fiscales"}>
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
<QuestionInput label="CUIT" value={cuit} onChange={(event) => setCuit(event.target.value.replace(/\D/g, ""))} placeholder="Sin guiones, por ejemplo 20123456789" inputMode="numeric" />
<QuestionInput label="Razón social" value={legalName} onChange={(event) => setLegalName(event.target.value)} placeholder="Nombre fiscal o razón social" />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "10px" }}>
<OptionButton active={taxCondition === "MONOTRIBUTO"} onClick={() => setTaxCondition("MONOTRIBUTO")}>Monotributo</OptionButton>
<OptionButton active={taxCondition === "EXENTO"} onClick={() => setTaxCondition("EXENTO")}>Exento</OptionButton>
</div>
<QuestionInput label="Punto de venta" value={pointOfSale} onChange={(event) => setPointOfSale(event.target.value.replace(/\D/g, ""))} placeholder="Ejemplo: 1" inputMode="numeric" />
<StatusMessage error={arcaError} />
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 2} title="Pedido de certificado" description="Ahora generamos el archivo CSR. Ese archivo se descarga desde TurnosXpress y luego se sube en ARCA para que te devuelvan el certificado." onNext={csrPem ? handleNext : handleGenerateCsr} disableNext={arcaLoading} nextLabel={csrPem ? "Continuar con ARCA" : arcaLoading ? "Generando..." : "Generar y descargar CSR"}>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<InfoPanel>{statusLabel || "Todavía no generamos el CSR para esta organización."}</InfoPanel>
{csrPem && <InfoPanel>Ya generamos el CSR. ARCA puede pedirte subir el archivo o pegar el contenido completo, incluyendo BEGIN y END CERTIFICATE REQUEST.</InfoPanel>}
{csrPem && (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<textarea value={csrPem} readOnly rows={9} aria-label="Contenido CSR para copiar en ARCA" onFocus={(event) => event.target.select()} style={{ width: "100%", boxSizing: "border-box", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", padding: "14px", fontFamily: "monospace", fontSize: "12px", lineHeight: 1.4, resize: "vertical" }} />
{csrCopyStatus && <InfoPanel>{csrCopyStatus}</InfoPanel>}
<SecondaryButton onClick={handleCopyCsr}>Copiar CSR</SecondaryButton>
</div>
)}
{csrPem && <SecondaryButton onClick={handleDownloadCsr}>Descargar archivo .csr</SecondaryButton>}
{csrPem && <SecondaryButton onClick={handleGenerateCsr}>Regenerar CSR</SecondaryButton>}
<StatusMessage error={arcaError} />
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 3} title="Subir el CSR en ARCA" description="Con el archivo descargado, entrá a ARCA y generá el certificado para facturación electrónica." onNext={handleNext} nextLabel="Ya tengo el certificado">
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<StepItem number="1">Ingresá a ARCA con clave fiscal y abrí el administrador de certificados digitales.</StepItem>
<StepItem number="2">Creá un nuevo certificado y subí el archivo .csr que descargaste desde TurnosXpress.</StepItem>
<StepItem number="3">Asociá el certificado al servicio de facturación electrónica WSFE.</StepItem>
<StepItem number="4">Descargá el certificado emitido por ARCA en formato .crt o .pem.</StepItem>
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 4} title="Cargar certificado" description="Pegá el contenido del certificado o seleccioná el archivo que descargaste desde ARCA." onNext={handleUploadCertificate} disableNext={!certificatePem.trim() || arcaLoading} nextLabel={arcaLoading ? "Validando..." : "Guardar certificado"}>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<input type="file" accept=".crt,.pem,text/plain" onChange={(event) => handleCertificateFile(event.target.files?.[0] || null)} style={{ color: "rgba(255,255,255,0.75)" }} />
<textarea value={certificatePem} onChange={(event) => setCertificatePem(event.target.value)} placeholder="-----BEGIN CERTIFICATE-----" rows={8} style={{ width: "100%", boxSizing: "border-box", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", padding: "14px", fontFamily: "monospace", resize: "vertical" }} />
<StatusMessage error={arcaError} />
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 5} title="ARCA conectado" description="Tu organización ya tiene los datos fiscales y el certificado guardados para avanzar con Factura C." onNext={handleFinish} nextLabel="Finalizar" topAccessory={<div style={{ fontSize: 48 }}></div>}>
<InfoPanel>{statusLabel || "Configuración lista."}</InfoPanel>
<div style={{ marginTop: "12px" }}>
<SecondaryButton onClick={handleEditFiscalProfile}>Editar datos fiscales</SecondaryButton>
</div>
</QuestionCard>
</>
);
}
function InfoPanel({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.75)", fontSize: "14px", lineHeight: 1.5 }}>{children}</div>;
}
function OptionButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return <button type="button" onClick={onClick} style={{ padding: "12px 16px", borderRadius: "10px", border: active ? "1px solid var(--wine-red)" : "1px solid rgba(255,255,255,0.16)", background: active ? "rgba(255,42,127,0.16)" : "rgba(255,255,255,0.05)", color: "white", cursor: "pointer", fontWeight: 700, fontFamily: "inherit" }}>{children}</button>;
}
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return <button type="button" onClick={onClick} style={{ padding: "12px 16px", borderRadius: "10px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.16)", color: "white", cursor: "pointer", fontWeight: 700, fontFamily: "inherit" }}>{children}</button>;
}
function StepItem({ number, children }: { number: string; children: React.ReactNode }) {
return <div style={{ display: "flex", gap: "12px", alignItems: "flex-start", padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.78)", lineHeight: 1.5 }}><strong style={{ color: "white" }}>{number}.</strong><span>{children}</span></div>;
}
function StatusMessage({ error }: { error: string | null }) {
if (!error) return null;
return <div style={{ padding: "12px 14px", borderRadius: "10px", background: "rgba(255,60,60,0.1)", border: "1px solid rgba(255,60,60,0.3)", color: "#ff9999", fontSize: "13px", lineHeight: 1.4 }}>{error}</div>;
}
@@ -0,0 +1,141 @@
import React from "react";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import QuestionCard from "../../QuestionCard/QuestionCard";
export type ArcaWsaaTestResult = {
status?: string;
environment?: string;
service?: string;
expirationTime?: string;
cached?: boolean;
};
export type ArcaWsfeDiagnosticsResult = {
environment?: string;
service?: string;
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;
};
};
type Props = {
currentStepIndex: number;
loading: boolean;
diagnosticsLoading: boolean;
error: string | null;
result: ArcaWsaaTestResult | null;
diagnostics: ArcaWsfeDiagnosticsResult | null;
handleTestConnection: () => void;
handleFinish: () => void;
};
export default function ArcaTestConnectionFlow({ currentStepIndex, loading, diagnosticsLoading, error, result, diagnostics, handleTestConnection, handleFinish }: Props) {
return (
<>
<QuestionCard
isActive={currentStepIndex === 0}
title="Probar conexión ARCA"
description="Vamos a validar que el certificado de esta organización pueda iniciar sesión en WSAA para usar el servicio WSFE."
onNext={handleTestConnection}
disableNext={loading}
nextLabel={loading ? "Probando..." : "Probar conexión"}
topAccessory={<ReceiptLongIcon style={{ fontSize: 52, color: "var(--wine-red)" }} />}
>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<InfoPanel>Esta prueba no emite facturas. Solo confirma que ARCA acepta el certificado y devuelve un token válido para WSFE.</InfoPanel>
<StatusMessage error={error} />
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 1 && Boolean(result)}
title="Conexión ARCA validada"
description="ARCA respondió correctamente el login WSAA. También consultamos WSFE para aislar problemas del servicio de facturación electrónica."
onNext={handleFinish}
disableNext={diagnosticsLoading}
nextLabel={diagnosticsLoading ? "Consultando WSFE..." : "Finalizar"}
topAccessory={<div style={{ fontSize: 48 }}></div>}
>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<InfoPanel>WSAA está OK. El diagnóstico WSFE no emite comprobantes: solo consulta salud, puntos de venta, tipos de comprobante y último número autorizado.</InfoPanel>
<InfoRow label="Ambiente" value={result?.environment || "homologation"} />
<InfoRow label="Servicio" value={result?.service || "wsfe"} />
<InfoRow label="Vencimiento" value={formatExpiration(result?.expirationTime)} />
<InfoRow label="Token" value={result?.cached ? "Reutilizado desde caché" : "Creado en esta prueba"} />
{diagnosticsLoading && <InfoPanel>Consultando FEDummy, puntos de venta, tipos de comprobante y último comprobante autorizado...</InfoPanel>}
{diagnostics && <WsfeDiagnosticsPanel diagnostics={diagnostics} />}
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 1 && Boolean(error) && !result}
title="No pudimos conectar con ARCA"
description="Revisá que el certificado esté cargado, corresponda al CSR generado y tenga el servicio WSFE asociado en ARCA."
onNext={handleTestConnection}
disableNext={loading}
nextLabel={loading ? "Probando..." : "Reintentar"}
topAccessory={<div style={{ fontSize: 48 }}></div>}
>
<StatusMessage error={error} />
</QuestionCard>
</>
);
}
function formatExpiration(value?: string) {
if (!value) return "No informado";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("es-AR", { dateStyle: "short", timeStyle: "short" });
}
function InfoPanel({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.75)", fontSize: "14px", lineHeight: 1.5 }}>{children}</div>;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return <div style={{ display: "flex", justifyContent: "space-between", gap: "12px", padding: "12px 14px", borderRadius: "10px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.78)", fontSize: "14px" }}><strong style={{ color: "white" }}>{label}</strong><span>{value}</span></div>;
}
function WsfeDiagnosticsPanel({ diagnostics }: { diagnostics: ArcaWsfeDiagnosticsResult }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<InfoRow label="WSFE FEDummy" value={diagnostics.dummy?.ok ? `OK (${[diagnostics.dummy.appServer, diagnostics.dummy.dbServer, diagnostics.dummy.authServer].filter(Boolean).join(" / ") || "sin detalle"})` : `Error: ${diagnostics.dummy?.error || "sin detalle"}`} />
<InfoRow label="Punto de venta configurado" value={diagnostics.pointsOfSale?.includesConfiguredPointOfSale ? `OK (${diagnostics.pointsOfSale.configuredPointOfSale})` : `No aparece (${diagnostics.pointsOfSale?.configuredPointOfSale || "sin configurar"})`} />
{diagnostics.pointsOfSale?.points?.length ? <InfoRow label="Puntos de venta ARCA" value={diagnostics.pointsOfSale.points.join(", ")} /> : null}
{diagnostics.pointsOfSale?.error ? <StatusMessage error={diagnostics.pointsOfSale.error} /> : null}
<InfoRow label="Factura C tipo 11" value={diagnostics.voucherTypes?.includesFacturaC ? `OK (${diagnostics.voucherTypes.count || 0} tipos)` : `No aparece (${diagnostics.voucherTypes?.count || 0} tipos)`} />
{diagnostics.voucherTypes?.error ? <StatusMessage error={diagnostics.voucherTypes.error} /> : null}
<InfoRow label="Último comprobante Factura C" value={diagnostics.lastVoucher?.ok ? String(diagnostics.lastVoucher.lastVoucherNumber ?? 0) : `Error: ${diagnostics.lastVoucher?.error || "sin detalle"}`} />
</div>
);
}
function StatusMessage({ error }: { error: string | null }) {
if (!error) return null;
return <div style={{ padding: "12px 14px", borderRadius: "10px", background: "rgba(255,60,60,0.1)", border: "1px solid rgba(255,60,60,0.3)", color: "#ff9999", fontSize: "13px", lineHeight: 1.4 }}>{error}</div>;
}