feat: implement ARCA integration architecture including credential, fiscal profile, and invoice models, services, and onboarding flows
This commit is contained in:
@@ -69,3 +69,7 @@ FILE_SERVER_URL = http://localhost:4000/
|
|||||||
FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/
|
FILE_SERVER_URL_prod = https://files.turnosxpress.com.ar/
|
||||||
|
|
||||||
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
|
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=
|
||||||
|
|||||||
@@ -67,3 +67,7 @@ SYSTEM_KEY = ad7c956a-76bf-45hdr60-8a50-2ca7b2180997
|
|||||||
FILE_SERVER_URL = https://files.turnosxpress.com.ar/
|
FILE_SERVER_URL = https://files.turnosxpress.com.ar/
|
||||||
|
|
||||||
SYS_ADMIN_API_PUBLIC_KEY = keys/tx-sysadmin-public.key
|
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=
|
||||||
|
|||||||
Generated
+103
-36
@@ -20,6 +20,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "8.8.3",
|
"mongoose": "8.8.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"node-forge": "^1.4.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"sanitize-html": "^2.17.6",
|
"sanitize-html": "^2.17.6",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/nodemailer": "^6.4.24",
|
"@types/nodemailer": "^6.4.24",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/socket.io": "^3.0.2",
|
"@types/socket.io": "^3.0.2",
|
||||||
@@ -775,9 +777,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -794,9 +793,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -813,9 +809,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -832,9 +825,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -851,9 +841,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -870,9 +857,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -889,9 +873,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -914,9 +895,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -939,9 +917,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -964,9 +939,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -989,9 +961,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1014,9 +983,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2119,6 +2085,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
|
||||||
"integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw=="
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.24",
|
"version": "6.4.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
|
||||||
@@ -6672,6 +6648,38 @@
|
|||||||
"url": "https://opencollective.com/mongoose"
|
"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": {
|
"node_modules/mongoose/node_modules/mongodb": {
|
||||||
"version": "6.10.0",
|
"version": "6.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"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": {
|
"node_modules/mpath": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
||||||
@@ -6892,6 +6950,15 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"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": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/express": "^4.17.25",
|
"@types/express": "^4.17.25",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/nodemailer": "^6.4.24",
|
"@types/nodemailer": "^6.4.24",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"@types/socket.io": "^3.0.2",
|
"@types/socket.io": "^3.0.2",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"mongoose": "8.8.3",
|
"mongoose": "8.8.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"node-forge": "^1.4.0",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"sanitize-html": "^2.17.6",
|
"sanitize-html": "^2.17.6",
|
||||||
"sharp": "^0.33.5",
|
"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(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLoginCmsReturn(xml: string): {
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
generationTime?: Date;
|
||||||
|
expirationTime: Date;
|
||||||
|
} {
|
||||||
|
const loginCmsReturn = this.extractXmlValue(xml, "loginCmsReturn");
|
||||||
|
const responseXml = loginCmsReturn ? this.decodeXmlEntities(loginCmsReturn) : xml;
|
||||||
|
const token = this.extractXmlValue(responseXml, "token");
|
||||||
|
const sign = this.extractXmlValue(responseXml, "sign");
|
||||||
|
const expirationTime = this.extractXmlValue(responseXml, "expirationTime");
|
||||||
|
const generationTime = this.extractXmlValue(responseXml, "generationTime");
|
||||||
|
|
||||||
|
if (!token || !sign || !expirationTime) {
|
||||||
|
throw new Error("ARCA WSAA no devolvió token, firma o vencimiento válidos");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
sign,
|
||||||
|
generationTime: generationTime ? new Date(generationTime) : undefined,
|
||||||
|
expirationTime: new Date(expirationTime),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWsaaLoginTicket(companyId: string): Promise<{
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
expirationTime: Date;
|
||||||
|
cached: boolean;
|
||||||
|
}> {
|
||||||
|
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||||
|
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
|
||||||
|
const cachedToken = await ArcaWsaaTokens.getValidToken({ companyId, service, environment });
|
||||||
|
|
||||||
|
if (cachedToken) {
|
||||||
|
return {
|
||||||
|
token: cachedToken.token,
|
||||||
|
sign: cachedToken.sign,
|
||||||
|
expirationTime: cachedToken.expirationTime,
|
||||||
|
cached: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = await this.arcaCredentials.findOne({
|
||||||
|
companyId,
|
||||||
|
status: ARCA_CREDENTIAL_STATUS.READY,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential?.certificatePem) {
|
||||||
|
throw new Error("Se requieren credenciales ARCA READY con certificado cargado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyPem = this.decryptPrivateKey(credential);
|
||||||
|
const { traXml } = this.buildTraXml(service);
|
||||||
|
const cms = this.signTraCms(traXml, credential.certificatePem, privateKeyPem);
|
||||||
|
const wsaaUrl = process.env.ARCA_WSAA_HOMOLOGATION_URL;
|
||||||
|
|
||||||
|
if (!wsaaUrl) {
|
||||||
|
throw new Error("ARCA_WSAA_HOMOLOGATION_URL no está configurada");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await axios.post(wsaaUrl, this.buildLoginCmsSoap(cms), {
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
|
SOAPAction: "loginCms",
|
||||||
|
},
|
||||||
|
transformResponse: [(body) => body],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(this.buildWsaaErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsaaToken = this.parseLoginCmsReturn(String(response.data));
|
||||||
|
const savedToken = await ArcaWsaaTokens.upsertToken({
|
||||||
|
companyId,
|
||||||
|
service,
|
||||||
|
environment,
|
||||||
|
...wsaaToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: savedToken.token,
|
||||||
|
sign: savedToken.sign,
|
||||||
|
expirationTime: savedToken.expirationTime,
|
||||||
|
cached: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsfeLastVoucherSoap(data: {
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
cuit: string;
|
||||||
|
pointOfSale: number;
|
||||||
|
voucherType: 11;
|
||||||
|
}): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<FECompUltimoAutorizado xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||||
|
<Auth>
|
||||||
|
<Token>${data.token}</Token>
|
||||||
|
<Sign>${data.sign}</Sign>
|
||||||
|
<Cuit>${data.cuit}</Cuit>
|
||||||
|
</Auth>
|
||||||
|
<PtoVta>${data.pointOfSale}</PtoVta>
|
||||||
|
<CbteTipo>${data.voucherType}</CbteTipo>
|
||||||
|
</FECompUltimoAutorizado>
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsfeDummySoap(): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<FEDummy xmlns="http://ar.gov.afip.dif.FEV1/" />
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsfeParamGetTiposCbteSoap(data: { token: string; sign: string; cuit: string }): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<FEParamGetTiposCbte xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||||
|
<Auth>
|
||||||
|
<Token>${data.token}</Token>
|
||||||
|
<Sign>${data.sign}</Sign>
|
||||||
|
<Cuit>${data.cuit}</Cuit>
|
||||||
|
</Auth>
|
||||||
|
</FEParamGetTiposCbte>
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsfeParamGetPtosVentaSoap(data: { token: string; sign: string; cuit: string }): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<FEParamGetPtosVenta xmlns="http://ar.gov.afip.dif.FEV1/">
|
||||||
|
<Auth>
|
||||||
|
<Token>${data.token}</Token>
|
||||||
|
<Sign>${data.sign}</Sign>
|
||||||
|
<Cuit>${data.cuit}</Cuit>
|
||||||
|
</Auth>
|
||||||
|
</FEParamGetPtosVenta>
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postWsfe(soapBody: string, soapAction: string): Promise<string> {
|
||||||
|
const wsfeUrl = process.env.ARCA_WSFE_HOMOLOGATION_URL;
|
||||||
|
|
||||||
|
if (!wsfeUrl) {
|
||||||
|
throw new Error("ARCA_WSFE_HOMOLOGATION_URL no está configurada");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(wsfeUrl, soapBody, {
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
|
SOAPAction: `"http://ar.gov.afip.dif.FEV1/${soapAction}"`,
|
||||||
|
},
|
||||||
|
transformResponse: [(body) => body],
|
||||||
|
});
|
||||||
|
|
||||||
|
return String(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsfeErrorMessage(error: unknown): string {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSFE";
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
|
||||||
|
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
|
||||||
|
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
|
||||||
|
|
||||||
|
if (decodedFault) {
|
||||||
|
return `ARCA WSFE rechazó la consulta: ${decodedFault}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlTitle = responseBody ? this.extractHtmlTitle(responseBody) : undefined;
|
||||||
|
|
||||||
|
if (htmlTitle && error.response?.status) {
|
||||||
|
return `ARCA WSFE respondió con estado ${error.response.status}: ${htmlTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "ECONNABORTED") {
|
||||||
|
return "ARCA WSFE no respondió a tiempo. Probá nuevamente en unos minutos.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.status) {
|
||||||
|
if (responseBody) {
|
||||||
|
const compactBody = responseBody.replace(/\s+/g, " ").trim().slice(0, 500);
|
||||||
|
return `ARCA WSFE respondió con estado ${error.response.status}: ${compactBody}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `ARCA WSFE respondió con estado ${error.response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message || "No pudimos conectar con ARCA WSFE";
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractHtmlTitle(html: string): string | undefined {
|
||||||
|
const title = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
|
||||||
|
return title ? this.decodeXmlEntities(title) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWsfeLastVoucherReturn(xml: string): number {
|
||||||
|
const faultString = this.extractXmlValue(xml, "faultstring");
|
||||||
|
|
||||||
|
if (faultString) {
|
||||||
|
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(faultString)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const voucherNumber = this.extractXmlValue(xml, "CbteNro");
|
||||||
|
|
||||||
|
if (!voucherNumber) {
|
||||||
|
const errorMessage = this.extractXmlValue(xml, "Msg");
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(`ARCA WSFE rechazó la consulta: ${this.decodeXmlEntities(errorMessage)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("ARCA WSFE no devolvió el último comprobante autorizado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedVoucherNumber = Number(voucherNumber);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsedVoucherNumber)) {
|
||||||
|
throw new Error("ARCA WSFE devolvió un número de comprobante inválido");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedVoucherNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWsfeDummyReturn(xml: string): ArcaWsfeDiagnosticsResult["dummy"] {
|
||||||
|
const errorMessage = this.extractWsfeResponseError(xml);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return { ok: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
appServer: this.extractXmlValue(xml, "AppServer"),
|
||||||
|
dbServer: this.extractXmlValue(xml, "DbServer"),
|
||||||
|
authServer: this.extractXmlValue(xml, "AuthServer"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWsfeVoucherTypesReturn(xml: string): ArcaWsfeDiagnosticsResult["voucherTypes"] {
|
||||||
|
const errorMessage = this.extractWsfeResponseError(xml);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return { ok: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.from(xml.matchAll(/<(?:\w+:)?Id>(\d+)<\/(?:\w+:)?Id>/g)).map((match) => Number(match[1]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
count: ids.length,
|
||||||
|
includesFacturaC: ids.includes(11),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWsfePointsOfSaleReturn(
|
||||||
|
xml: string,
|
||||||
|
configuredPointOfSale: number
|
||||||
|
): ArcaWsfeDiagnosticsResult["pointsOfSale"] {
|
||||||
|
const errorMessage = this.extractWsfeResponseError(xml);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
return { ok: false, configuredPointOfSale, error: errorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = Array.from(xml.matchAll(/<(?:\w+:)?PtoVenta>(\d+)<\/(?:\w+:)?PtoVenta>/g)).map((match) => Number(match[1]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
points,
|
||||||
|
includesConfiguredPointOfSale: points.includes(configuredPointOfSale),
|
||||||
|
configuredPointOfSale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractWsfeResponseError(xml: string): string | undefined {
|
||||||
|
const faultString = this.extractXmlValue(xml, "faultstring");
|
||||||
|
const msg = this.extractXmlValue(xml, "Msg");
|
||||||
|
const errorMessage = faultString || msg;
|
||||||
|
return errorMessage ? this.decodeXmlEntities(errorMessage) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWsaaErrorMessage(error: unknown): string {
|
||||||
|
if (!axios.isAxiosError(error)) {
|
||||||
|
return error instanceof Error ? error.message : "No pudimos conectar con ARCA WSAA";
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = typeof error.response?.data === "string" ? error.response.data : "";
|
||||||
|
const faultString = responseBody ? this.extractXmlValue(responseBody, "faultstring") : undefined;
|
||||||
|
const decodedFault = faultString ? this.decodeXmlEntities(faultString) : undefined;
|
||||||
|
|
||||||
|
if (decodedFault) {
|
||||||
|
return `ARCA WSAA rechazó la conexión: ${decodedFault}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === "ECONNABORTED") {
|
||||||
|
return "ARCA WSAA no respondió a tiempo. Probá nuevamente en unos minutos.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.status) {
|
||||||
|
return `ARCA WSAA respondió con estado ${error.response.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message || "No pudimos conectar con ARCA WSAA";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
||||||
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
|
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const csr = forge.pki.createCertificationRequest();
|
||||||
|
csr.publicKey = keys.publicKey;
|
||||||
|
csr.setSubject([
|
||||||
|
{ name: "commonName", value: fiscalProfile.cuit },
|
||||||
|
{ name: "organizationName", value: fiscalProfile.legalName },
|
||||||
|
{ name: "serialNumber", value: `CUIT ${fiscalProfile.cuit}` },
|
||||||
|
{ name: "countryName", value: "AR" },
|
||||||
|
]);
|
||||||
|
csr.sign(keys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
if (!csr.verify()) {
|
||||||
|
throw new Error("No se pudo verificar el CSR generado para ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrPem = forge.pki.certificationRequestToPem(csr);
|
||||||
|
const privateKeyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
||||||
|
const encryptedPrivateKey = this.encryptPrivateKey(privateKeyPem);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
|
||||||
|
const credentialData = {
|
||||||
|
companyId: data.companyId,
|
||||||
|
csrPem,
|
||||||
|
...encryptedPrivateKey,
|
||||||
|
encryptionKeyVersion: process.env.ARCA_CREDENTIALS_KEY_VERSION || "v1",
|
||||||
|
status: ARCA_CREDENTIAL_STATUS.CSR_GENERATED,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const credential = existingCredential
|
||||||
|
? await this.arcaCredentials.update(credentialData)
|
||||||
|
: await this.arcaCredentials.create({
|
||||||
|
...credentialData,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error("No se pudieron guardar las credenciales ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sanitizeCredential(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
||||||
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
|
||||||
|
if (!data.certificatePem || !data.certificatePem.trim()) {
|
||||||
|
throw new Error("Se requiere el certificado ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCredential = await this.arcaCredentials.findOne({ companyId: data.companyId });
|
||||||
|
|
||||||
|
if (
|
||||||
|
!existingCredential ||
|
||||||
|
!existingCredential.encryptedPrivateKey ||
|
||||||
|
!existingCredential.privateKeyIv ||
|
||||||
|
!existingCredential.privateKeyAuthTag
|
||||||
|
) {
|
||||||
|
throw new Error("Primero se deben generar las credenciales ARCA para la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificatePem = data.certificatePem.trim();
|
||||||
|
let certificate: forge.pki.Certificate;
|
||||||
|
let privateKeyPem: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
certificate = this.parseCertificate(certificatePem);
|
||||||
|
privateKeyPem = this.decryptPrivateKey(existingCredential);
|
||||||
|
|
||||||
|
if (!this.certificateMatchesPrivateKey(certificate, privateKeyPem)) {
|
||||||
|
throw new Error("El certificado ARCA no corresponde a la clave privada generada para esta organización");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorOccurred = error as Error;
|
||||||
|
await this.arcaCredentials.update({
|
||||||
|
companyId: data.companyId,
|
||||||
|
lastValidationError: errorOccurred.message,
|
||||||
|
status: ARCA_CREDENTIAL_STATUS.INVALID,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
throw errorOccurred;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCredential = await this.arcaCredentials.update({
|
||||||
|
companyId: data.companyId,
|
||||||
|
certificatePem,
|
||||||
|
certificateValidFrom: certificate.validity.notBefore,
|
||||||
|
certificateValidTo: certificate.validity.notAfter,
|
||||||
|
lastValidationError: undefined,
|
||||||
|
status: ARCA_CREDENTIAL_STATUS.READY,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedCredential) {
|
||||||
|
throw new Error("No se pudieron actualizar las credenciales ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sanitizeCredential(updatedCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
||||||
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
|
||||||
|
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||||
|
const environment = ARCA_WSAA_ENVIRONMENT.HOMOLOGATION;
|
||||||
|
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: loginTicket.cached ? "cached" : "created",
|
||||||
|
environment,
|
||||||
|
service,
|
||||||
|
expirationTime: loginTicket.expirationTime,
|
||||||
|
cached: loginTicket.cached,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
||||||
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
|
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||||
|
const voucherType = 11;
|
||||||
|
let responseXml: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
responseXml = await this.postWsfe(
|
||||||
|
this.buildWsfeLastVoucherSoap({
|
||||||
|
token: loginTicket.token,
|
||||||
|
sign: loginTicket.sign,
|
||||||
|
cuit: fiscalProfile.cuit,
|
||||||
|
pointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
voucherType,
|
||||||
|
}),
|
||||||
|
"FECompUltimoAutorizado"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(this.buildWsfeErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
|
||||||
|
service: ARCA_WSAA_SERVICE.WSFE,
|
||||||
|
pointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
voucherType,
|
||||||
|
lastVoucherNumber: this.parseWsfeLastVoucherReturn(responseXml),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
||||||
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
|
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||||
|
const voucherType = 11;
|
||||||
|
|
||||||
|
const diagnostics: ArcaWsfeDiagnosticsResult = {
|
||||||
|
environment: ARCA_WSAA_ENVIRONMENT.HOMOLOGATION,
|
||||||
|
service: ARCA_WSAA_SERVICE.WSFE,
|
||||||
|
dummy: { ok: false },
|
||||||
|
voucherTypes: { ok: false },
|
||||||
|
pointsOfSale: {
|
||||||
|
ok: false,
|
||||||
|
configuredPointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
},
|
||||||
|
lastVoucher: {
|
||||||
|
ok: false,
|
||||||
|
pointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
voucherType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
diagnostics.dummy = this.parseWsfeDummyReturn(
|
||||||
|
await this.postWsfe(this.buildWsfeDummySoap(), "FEDummy")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.dummy = { ok: false, error: this.buildWsfeErrorMessage(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diagnostics.voucherTypes = this.parseWsfeVoucherTypesReturn(
|
||||||
|
await this.postWsfe(
|
||||||
|
this.buildWsfeParamGetTiposCbteSoap({
|
||||||
|
token: loginTicket.token,
|
||||||
|
sign: loginTicket.sign,
|
||||||
|
cuit: fiscalProfile.cuit,
|
||||||
|
}),
|
||||||
|
"FEParamGetTiposCbte"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.voucherTypes = { ok: false, error: this.buildWsfeErrorMessage(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diagnostics.pointsOfSale = this.parseWsfePointsOfSaleReturn(
|
||||||
|
await this.postWsfe(
|
||||||
|
this.buildWsfeParamGetPtosVentaSoap({
|
||||||
|
token: loginTicket.token,
|
||||||
|
sign: loginTicket.sign,
|
||||||
|
cuit: fiscalProfile.cuit,
|
||||||
|
}),
|
||||||
|
"FEParamGetPtosVenta"
|
||||||
|
),
|
||||||
|
fiscalProfile.pointOfSale
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.pointsOfSale = {
|
||||||
|
ok: false,
|
||||||
|
configuredPointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
error: this.buildWsfeErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diagnostics.lastVoucher.lastVoucherNumber = this.parseWsfeLastVoucherReturn(
|
||||||
|
await this.postWsfe(
|
||||||
|
this.buildWsfeLastVoucherSoap({
|
||||||
|
token: loginTicket.token,
|
||||||
|
sign: loginTicket.sign,
|
||||||
|
cuit: fiscalProfile.cuit,
|
||||||
|
pointOfSale: fiscalProfile.pointOfSale,
|
||||||
|
voucherType,
|
||||||
|
}),
|
||||||
|
"FECompUltimoAutorizado"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
diagnostics.lastVoucher.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.lastVoucher.error = this.buildWsfeErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(data: FindArcaCredentialsParams): Promise<ArcaCredentialView[]> {
|
||||||
|
if (!data.sessionUser) {
|
||||||
|
throw new Error("Se requiere un usuario autenticado");
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
if (data.companyId) {
|
||||||
|
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||||
|
} else if (data.id) {
|
||||||
|
const credential = await this.arcaCredentials.findOne(data);
|
||||||
|
|
||||||
|
if (!credential) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: String(credential.companyId),
|
||||||
|
sessionUser: data.sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [this.sanitizeCredential(credential)];
|
||||||
|
} else {
|
||||||
|
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await this.arcaCredentials.find(data);
|
||||||
|
return credentials.map((credential) => this.sanitizeCredential(credential));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ArcaCredentialsManager();
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Document, FilterQuery, Model, Schema, model, models } from "mongoose";
|
||||||
|
import {
|
||||||
|
ARCA_WSAA_ENVIRONMENT,
|
||||||
|
ARCA_WSAA_SERVICE,
|
||||||
|
ArcaWsaaTokenLookupParams,
|
||||||
|
CreateArcaWsaaTokenParams,
|
||||||
|
IArcaWsaaToken,
|
||||||
|
IArcaWsaaTokensAdapter,
|
||||||
|
UpdateArcaWsaaTokenParams,
|
||||||
|
} from "./ArcaWsaaTokens.Interface";
|
||||||
|
|
||||||
|
export interface IArcaWsaaTokenDocument extends Omit<IArcaWsaaToken, "id">, Document {}
|
||||||
|
|
||||||
|
export class ArcaWsaaTokensAdapterMongoose implements IArcaWsaaTokensAdapter {
|
||||||
|
schema: Schema;
|
||||||
|
arcaWsaaTokenList: Model<IArcaWsaaTokenDocument>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.schema = new Schema({
|
||||||
|
companyId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
required: true,
|
||||||
|
ref: "Company",
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: Object.values(ARCA_WSAA_SERVICE),
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: Object.values(ARCA_WSAA_ENVIRONMENT),
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
token: { type: String, required: true },
|
||||||
|
sign: { type: String, required: true },
|
||||||
|
generationTime: { type: Date, required: false },
|
||||||
|
expirationTime: { type: Date, required: true, index: true },
|
||||||
|
createdAt: { type: Date, required: true, default: Date.now },
|
||||||
|
updatedAt: { type: Date, required: true, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.schema.index({ companyId: 1, service: 1, environment: 1 }, { unique: true });
|
||||||
|
|
||||||
|
this.arcaWsaaTokenList =
|
||||||
|
models.ArcaWsaaToken || model<IArcaWsaaTokenDocument>("ArcaWsaaToken", this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||||
|
return await this.arcaWsaaTokenList.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSearchCriteria(
|
||||||
|
filters: Partial<ArcaWsaaTokenLookupParams>
|
||||||
|
): FilterQuery<IArcaWsaaToken> {
|
||||||
|
const searchCriteria: FilterQuery<IArcaWsaaToken> = {};
|
||||||
|
|
||||||
|
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||||
|
if (filters.service) searchCriteria.service = filters.service;
|
||||||
|
if (filters.environment) searchCriteria.environment = filters.environment;
|
||||||
|
|
||||||
|
return searchCriteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]> {
|
||||||
|
return this.arcaWsaaTokenList.find(this.buildSearchCriteria(filters)).sort({ createdAt: -1 }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOne(
|
||||||
|
filters: Partial<ArcaWsaaTokenLookupParams>
|
||||||
|
): Promise<IArcaWsaaTokenDocument | null> {
|
||||||
|
return this.arcaWsaaTokenList.findOne(this.buildSearchCriteria(filters)).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
|
||||||
|
const { companyId, service, environment, ...updateData } = data;
|
||||||
|
return this.arcaWsaaTokenList
|
||||||
|
.findOneAndUpdate(this.buildSearchCriteria({ companyId, service, environment }), updateData, {
|
||||||
|
new: true,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null> {
|
||||||
|
const { companyId, service, environment, createdAt, ...updateData } = data;
|
||||||
|
return this.arcaWsaaTokenList
|
||||||
|
.findOneAndUpdate(
|
||||||
|
this.buildSearchCriteria({ companyId, service, environment }),
|
||||||
|
{ $set: updateData, $setOnInsert: { companyId, service, environment, createdAt } },
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { IArcaWsaaTokenDocument } from "./ArcaWsaaTokens.Adapter.Mongoose";
|
||||||
|
|
||||||
|
export enum ARCA_WSAA_SERVICE {
|
||||||
|
WSFE = "wsfe",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ARCA_WSAA_ENVIRONMENT {
|
||||||
|
HOMOLOGATION = "homologation",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArcaWsaaTokenLookupParams = {
|
||||||
|
companyId: string;
|
||||||
|
service: ARCA_WSAA_SERVICE;
|
||||||
|
environment: ARCA_WSAA_ENVIRONMENT;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
generationTime?: Date;
|
||||||
|
expirationTime: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||||
|
token?: string;
|
||||||
|
sign?: string;
|
||||||
|
generationTime?: Date;
|
||||||
|
expirationTime?: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpsertArcaWsaaTokenParams = ArcaWsaaTokenLookupParams & {
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
generationTime?: Date;
|
||||||
|
expirationTime: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IArcaWsaaToken {
|
||||||
|
id?: string;
|
||||||
|
companyId: string;
|
||||||
|
service: ARCA_WSAA_SERVICE;
|
||||||
|
environment: ARCA_WSAA_ENVIRONMENT;
|
||||||
|
token: string;
|
||||||
|
sign: string;
|
||||||
|
generationTime?: Date;
|
||||||
|
expirationTime: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IArcaWsaaTokensAdapter {
|
||||||
|
create(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||||
|
find(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaToken[]>;
|
||||||
|
findOne(filters: Partial<ArcaWsaaTokenLookupParams>): Promise<IArcaWsaaTokenDocument | null>;
|
||||||
|
update(data: UpdateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
|
||||||
|
upsert(data: CreateArcaWsaaTokenParams): Promise<IArcaWsaaTokenDocument | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IArcaWsaaTokensManager {
|
||||||
|
arcaWsaaTokens: IArcaWsaaTokensAdapter;
|
||||||
|
getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null>;
|
||||||
|
saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||||
|
upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ArcaWsaaTokensAdapterMongoose } from "./ArcaWsaaTokens.Adapter.Mongoose";
|
||||||
|
import {
|
||||||
|
ArcaWsaaTokenLookupParams,
|
||||||
|
IArcaWsaaToken,
|
||||||
|
IArcaWsaaTokensManager,
|
||||||
|
UpsertArcaWsaaTokenParams,
|
||||||
|
} from "./ArcaWsaaTokens.Interface";
|
||||||
|
|
||||||
|
const TOKEN_REUSE_SKEW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
class ArcaWsaaTokensManager implements IArcaWsaaTokensManager {
|
||||||
|
arcaWsaaTokens: ArcaWsaaTokensAdapterMongoose;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.arcaWsaaTokens = new ArcaWsaaTokensAdapterMongoose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getValidToken(data: ArcaWsaaTokenLookupParams): Promise<IArcaWsaaToken | null> {
|
||||||
|
const existingToken = await this.arcaWsaaTokens.findOne(data);
|
||||||
|
|
||||||
|
if (!existingToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reuseUntil = Date.now() + TOKEN_REUSE_SKEW_MS;
|
||||||
|
if (new Date(existingToken.expirationTime).getTime() <= reuseUntil) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||||
|
const now = new Date();
|
||||||
|
return await this.arcaWsaaTokens.create({
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertToken(data: UpsertArcaWsaaTokenParams): Promise<IArcaWsaaToken> {
|
||||||
|
const now = new Date();
|
||||||
|
const token = await this.arcaWsaaTokens.upsert({
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("No se pudo guardar el token WSAA de ARCA");
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ArcaWsaaTokensManager();
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||||
|
import {
|
||||||
|
CreateInvoiceParams,
|
||||||
|
FindInvoicesParams,
|
||||||
|
IInvoice,
|
||||||
|
IInvoicesAdapter,
|
||||||
|
INVOICE_STATUS,
|
||||||
|
INVOICE_TYPE,
|
||||||
|
} from "./Invoices.Interface";
|
||||||
|
|
||||||
|
export interface IInvoiceDocument extends Omit<IInvoice, "id">, Document {}
|
||||||
|
|
||||||
|
export class InvoicesAdapterMongoose implements IInvoicesAdapter {
|
||||||
|
schema: Schema;
|
||||||
|
invoiceList: Model<IInvoiceDocument>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.schema = new Schema({
|
||||||
|
cashMovementId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
required: true,
|
||||||
|
ref: "CashFlow",
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
companyId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
required: true,
|
||||||
|
ref: "Company",
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
clientId: { type: Schema.Types.ObjectId, required: false, ref: "Client" },
|
||||||
|
paymentId: { type: Schema.Types.ObjectId, required: false, ref: "Payment" },
|
||||||
|
clientAccountMovementId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
required: false,
|
||||||
|
ref: "ClientAccountMovement",
|
||||||
|
},
|
||||||
|
appointmentId: { type: Schema.Types.ObjectId, required: false, ref: "Appointment" },
|
||||||
|
amount: { type: Number, required: true },
|
||||||
|
type: { type: String, required: true, enum: Object.values(INVOICE_TYPE) },
|
||||||
|
status: { type: String, required: true, enum: Object.values(INVOICE_STATUS), index: true },
|
||||||
|
cae: { type: String, required: false },
|
||||||
|
caeExpiresAt: { type: Date, required: false },
|
||||||
|
pointOfSale: { type: Number, required: false },
|
||||||
|
voucherNumber: { type: Number, required: false },
|
||||||
|
voucherType: { type: String, required: false },
|
||||||
|
arcaErrorCode: { type: String, required: false },
|
||||||
|
arcaErrorMessage: { type: String, required: false },
|
||||||
|
createdAt: { type: Date, required: true, default: Date.now },
|
||||||
|
updatedAt: { type: Date, required: true, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.invoiceList = model<IInvoiceDocument>("Invoice", this.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(data: CreateInvoiceParams): Promise<IInvoice> {
|
||||||
|
return await this.invoiceList.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSearchCriteria(filters: FindInvoicesParams): FilterQuery<IInvoice> {
|
||||||
|
const searchCriteria: FilterQuery<IInvoice> = {};
|
||||||
|
|
||||||
|
if (filters.id) searchCriteria._id = filters.id;
|
||||||
|
if (filters.cashMovementId) searchCriteria.cashMovementId = filters.cashMovementId;
|
||||||
|
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||||
|
if (filters.status) searchCriteria.status = filters.status;
|
||||||
|
|
||||||
|
return searchCriteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(filters: FindInvoicesParams): Promise<IInvoice[]> {
|
||||||
|
const searchCriteria = this.buildSearchCriteria(filters);
|
||||||
|
return this.invoiceList.find(searchCriteria).sort({ createdAt: -1 }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null> {
|
||||||
|
const searchCriteria = this.buildSearchCriteria(filters);
|
||||||
|
return this.invoiceList.findOne(searchCriteria).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { IInvoiceDocument } from "./Invoices.Adapter.Mongoose";
|
||||||
|
|
||||||
|
export enum INVOICE_TYPE {
|
||||||
|
FACTURA_C = "FACTURA_C",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum INVOICE_STATUS {
|
||||||
|
PENDING_ARCA = "PENDING_ARCA",
|
||||||
|
APPROVED = "APPROVED",
|
||||||
|
PENDING_VERIFICATION = "PENDING_VERIFICATION",
|
||||||
|
REJECTED = "REJECTED",
|
||||||
|
FAILED = "FAILED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateInvoiceFromCashMovementParams = {
|
||||||
|
cashMovementId: string;
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FindInvoicesParams = {
|
||||||
|
id?: string;
|
||||||
|
cashMovementId?: string;
|
||||||
|
companyId?: string;
|
||||||
|
status?: INVOICE_STATUS;
|
||||||
|
sessionUser?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateInvoiceParams = {
|
||||||
|
cashMovementId: string;
|
||||||
|
companyId: string;
|
||||||
|
clientId?: string;
|
||||||
|
paymentId?: string;
|
||||||
|
clientAccountMovementId?: string;
|
||||||
|
appointmentId?: string;
|
||||||
|
amount: number;
|
||||||
|
type: INVOICE_TYPE;
|
||||||
|
status: INVOICE_STATUS;
|
||||||
|
cae?: string;
|
||||||
|
caeExpiresAt?: Date;
|
||||||
|
pointOfSale?: number;
|
||||||
|
voucherNumber?: number;
|
||||||
|
voucherType?: string;
|
||||||
|
arcaErrorCode?: string;
|
||||||
|
arcaErrorMessage?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IInvoice {
|
||||||
|
id?: string;
|
||||||
|
cashMovementId: string;
|
||||||
|
companyId: string;
|
||||||
|
clientId?: string;
|
||||||
|
paymentId?: string;
|
||||||
|
clientAccountMovementId?: string;
|
||||||
|
appointmentId?: string;
|
||||||
|
amount: number;
|
||||||
|
type: INVOICE_TYPE;
|
||||||
|
status: INVOICE_STATUS;
|
||||||
|
cae?: string;
|
||||||
|
caeExpiresAt?: Date;
|
||||||
|
pointOfSale?: number;
|
||||||
|
voucherNumber?: number;
|
||||||
|
voucherType?: string;
|
||||||
|
arcaErrorCode?: string;
|
||||||
|
arcaErrorMessage?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInvoicesAdapter {
|
||||||
|
create(data: CreateInvoiceParams): Promise<IInvoice>;
|
||||||
|
find(filters: FindInvoicesParams): Promise<IInvoice[]>;
|
||||||
|
findOne(filters: FindInvoicesParams): Promise<IInvoiceDocument | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInvoicesManager {
|
||||||
|
invoices: IInvoicesAdapter;
|
||||||
|
createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice>;
|
||||||
|
find(data: FindInvoicesParams): Promise<IInvoice[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import CashFlow from "../CashFlow/CashFlow";
|
||||||
|
import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "../ClientAccountMovements/ClientAccountMovements.Interface";
|
||||||
|
import OrganizationFiscalProfiles from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles";
|
||||||
|
import { ORGANIZATION_FISCAL_PROFILE_STATUS } from "../OrganizationFiscalProfiles/OrganizationFiscalProfiles.Interface";
|
||||||
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||||
|
import { InvoicesAdapterMongoose } from "./Invoices.Adapter.Mongoose";
|
||||||
|
import {
|
||||||
|
CreateInvoiceFromCashMovementParams,
|
||||||
|
FindInvoicesParams,
|
||||||
|
IInvoice,
|
||||||
|
IInvoicesManager,
|
||||||
|
INVOICE_STATUS,
|
||||||
|
INVOICE_TYPE,
|
||||||
|
} from "./Invoices.Interface";
|
||||||
|
|
||||||
|
class InvoicesManager implements IInvoicesManager {
|
||||||
|
invoices: InvoicesAdapterMongoose;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.invoices = new InvoicesAdapterMongoose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDuplicateKeyError(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createFromCashMovement(data: CreateInvoiceFromCashMovementParams): Promise<IInvoice> {
|
||||||
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
if (!data.cashMovementId || !data.cashMovementId.trim()) {
|
||||||
|
throw new Error("Se requiere un movimiento de caja para emitir la factura");
|
||||||
|
}
|
||||||
|
|
||||||
|
const movement = await CashFlow.movements.movementList.findOne({
|
||||||
|
_id: data.cashMovementId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!movement) {
|
||||||
|
throw new Error("No se ha encontrado el movimiento de caja");
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = String(movement.companyId);
|
||||||
|
await validatePermissionsByCompany({ companyId, sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
if (movement.type !== CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT || movement.amount <= 0) {
|
||||||
|
throw new Error("El movimiento de caja no es facturable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiscalProfiles = await OrganizationFiscalProfiles.find({
|
||||||
|
companyId,
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||||
|
sessionUser: data.sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fiscalProfiles.length === 0) {
|
||||||
|
throw new Error("La organización debe tener un perfil fiscal activo para emitir facturas");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingInvoice = await this.invoices.findOne({
|
||||||
|
cashMovementId: data.cashMovementId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingInvoice) {
|
||||||
|
return existingInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.invoices.create({
|
||||||
|
cashMovementId: data.cashMovementId,
|
||||||
|
companyId,
|
||||||
|
clientId: movement.clientId ? String(movement.clientId) : undefined,
|
||||||
|
paymentId: movement.paymentId ? String(movement.paymentId) : undefined,
|
||||||
|
clientAccountMovementId: movement.clientAccountMovementId
|
||||||
|
? String(movement.clientAccountMovementId)
|
||||||
|
: undefined,
|
||||||
|
appointmentId: movement.appointmentId ? String(movement.appointmentId) : undefined,
|
||||||
|
amount: movement.amount,
|
||||||
|
type: INVOICE_TYPE.FACTURA_C,
|
||||||
|
status: INVOICE_STATUS.PENDING_ARCA,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDuplicateKeyError(error)) {
|
||||||
|
const duplicateInvoice = await this.invoices.findOne({
|
||||||
|
cashMovementId: data.cashMovementId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateInvoice) {
|
||||||
|
return duplicateInvoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(data: FindInvoicesParams): Promise<IInvoice[]> {
|
||||||
|
if (!data.sessionUser) {
|
||||||
|
throw new Error("Se requiere un usuario autenticado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser = data.sessionUser;
|
||||||
|
await validateSessionUser({ sessionUser });
|
||||||
|
|
||||||
|
if (data.companyId) {
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: data.companyId,
|
||||||
|
sessionUser,
|
||||||
|
});
|
||||||
|
} else if (data.id || data.cashMovementId) {
|
||||||
|
const invoice = await this.invoices.findOne(data);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: String(invoice.companyId),
|
||||||
|
sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [invoice];
|
||||||
|
} else {
|
||||||
|
throw new Error("Se requiere una organización para buscar facturas");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.invoices.find(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new InvoicesManager();
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
import { Document, FilterQuery, Model, Schema, model } from "mongoose";
|
||||||
|
import {
|
||||||
|
CreateOrganizationFiscalProfileParams,
|
||||||
|
FindOrganizationFiscalProfilesParams,
|
||||||
|
IOrganizationFiscalProfile,
|
||||||
|
IOrganizationFiscalProfilesAdapter,
|
||||||
|
ORGANIZATION_FISCAL_PROFILE_STATUS,
|
||||||
|
ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION,
|
||||||
|
UpdateOrganizationFiscalProfileParams,
|
||||||
|
} from "./OrganizationFiscalProfiles.Interface";
|
||||||
|
|
||||||
|
export interface IOrganizationFiscalProfileDocument
|
||||||
|
extends Omit<IOrganizationFiscalProfile, "id">,
|
||||||
|
Document {}
|
||||||
|
|
||||||
|
export class OrganizationFiscalProfilesAdapterMongoose implements IOrganizationFiscalProfilesAdapter {
|
||||||
|
schema: Schema;
|
||||||
|
organizationFiscalProfileList: Model<IOrganizationFiscalProfileDocument>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.schema = new Schema({
|
||||||
|
companyId: {
|
||||||
|
type: Schema.Types.ObjectId,
|
||||||
|
required: true,
|
||||||
|
ref: "Company",
|
||||||
|
unique: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
cuit: { type: String, required: true },
|
||||||
|
legalName: { type: String, required: true },
|
||||||
|
taxCondition: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION),
|
||||||
|
},
|
||||||
|
pointOfSale: { type: Number, required: true },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
enum: Object.values(ORGANIZATION_FISCAL_PROFILE_STATUS),
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
createdAt: { type: Date, required: true, default: Date.now },
|
||||||
|
updatedAt: { type: Date, required: true, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.organizationFiscalProfileList = model<IOrganizationFiscalProfileDocument>(
|
||||||
|
"OrganizationFiscalProfile",
|
||||||
|
this.schema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(
|
||||||
|
data: CreateOrganizationFiscalProfileParams
|
||||||
|
): Promise<IOrganizationFiscalProfile> {
|
||||||
|
return await this.organizationFiscalProfileList.create(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSearchCriteria(
|
||||||
|
filters: FindOrganizationFiscalProfilesParams
|
||||||
|
): FilterQuery<IOrganizationFiscalProfile> {
|
||||||
|
const searchCriteria: FilterQuery<IOrganizationFiscalProfile> = {};
|
||||||
|
|
||||||
|
if (filters.id) searchCriteria._id = filters.id;
|
||||||
|
if (filters.companyId) searchCriteria.companyId = filters.companyId;
|
||||||
|
if (filters.status) searchCriteria.status = filters.status;
|
||||||
|
|
||||||
|
return searchCriteria;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(
|
||||||
|
filters: FindOrganizationFiscalProfilesParams
|
||||||
|
): Promise<IOrganizationFiscalProfile[]> {
|
||||||
|
const searchCriteria = this.buildSearchCriteria(filters);
|
||||||
|
return this.organizationFiscalProfileList.find(searchCriteria).sort({ createdAt: -1 }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async findOne(
|
||||||
|
filters: FindOrganizationFiscalProfilesParams
|
||||||
|
): Promise<IOrganizationFiscalProfileDocument | null> {
|
||||||
|
const searchCriteria = this.buildSearchCriteria(filters);
|
||||||
|
return this.organizationFiscalProfileList.findOne(searchCriteria).exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
data: UpdateOrganizationFiscalProfileParams
|
||||||
|
): Promise<IOrganizationFiscalProfileDocument | null> {
|
||||||
|
const { id, companyId, sessionUser, ...updateData } = data;
|
||||||
|
|
||||||
|
if (!id && !companyId) {
|
||||||
|
throw new Error("Se requiere un identificador para actualizar el perfil fiscal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.organizationFiscalProfileList
|
||||||
|
.findOneAndUpdate(this.buildSearchCriteria({ id, companyId, sessionUser }), updateData, {
|
||||||
|
new: true,
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { IOrganizationFiscalProfileDocument } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||||
|
|
||||||
|
export enum ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION {
|
||||||
|
MONOTRIBUTO = "MONOTRIBUTO",
|
||||||
|
EXENTO = "EXENTO",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ORGANIZATION_FISCAL_PROFILE_STATUS {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
ACTIVE = "ACTIVE",
|
||||||
|
DISABLED = "DISABLED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpsertOrganizationFiscalProfileParams = {
|
||||||
|
companyId: string;
|
||||||
|
cuit: string;
|
||||||
|
legalName: string;
|
||||||
|
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||||
|
pointOfSale: number;
|
||||||
|
sessionUser: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FindOrganizationFiscalProfilesParams = {
|
||||||
|
id?: string;
|
||||||
|
companyId?: string;
|
||||||
|
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||||
|
sessionUser?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateOrganizationFiscalProfileParams = {
|
||||||
|
companyId: string;
|
||||||
|
cuit: string;
|
||||||
|
legalName: string;
|
||||||
|
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||||
|
pointOfSale: number;
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateOrganizationFiscalProfileParams = FindOrganizationFiscalProfilesParams & {
|
||||||
|
cuit?: string;
|
||||||
|
legalName?: string;
|
||||||
|
taxCondition?: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||||
|
pointOfSale?: number;
|
||||||
|
status?: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IOrganizationFiscalProfile {
|
||||||
|
id?: string;
|
||||||
|
companyId: string;
|
||||||
|
cuit: string;
|
||||||
|
legalName: string;
|
||||||
|
taxCondition: ORGANIZATION_FISCAL_PROFILE_TAX_CONDITION;
|
||||||
|
pointOfSale: number;
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationFiscalProfilesAdapter {
|
||||||
|
create(data: CreateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
|
||||||
|
find(filters: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
|
||||||
|
findOne(
|
||||||
|
filters: FindOrganizationFiscalProfilesParams
|
||||||
|
): Promise<IOrganizationFiscalProfileDocument | null>;
|
||||||
|
update(data: UpdateOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfileDocument | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOrganizationFiscalProfilesManager {
|
||||||
|
organizationFiscalProfiles: IOrganizationFiscalProfilesAdapter;
|
||||||
|
upsert(data: UpsertOrganizationFiscalProfileParams): Promise<IOrganizationFiscalProfile>;
|
||||||
|
find(data: FindOrganizationFiscalProfilesParams): Promise<IOrganizationFiscalProfile[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||||
|
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||||
|
import {
|
||||||
|
FindOrganizationFiscalProfilesParams,
|
||||||
|
IOrganizationFiscalProfile,
|
||||||
|
IOrganizationFiscalProfilesManager,
|
||||||
|
ORGANIZATION_FISCAL_PROFILE_STATUS,
|
||||||
|
UpsertOrganizationFiscalProfileParams,
|
||||||
|
} from "./OrganizationFiscalProfiles.Interface";
|
||||||
|
|
||||||
|
class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesManager {
|
||||||
|
organizationFiscalProfiles: OrganizationFiscalProfilesAdapterMongoose;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.organizationFiscalProfiles = new OrganizationFiscalProfilesAdapterMongoose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDuplicateKeyError(error: unknown): boolean {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error && error.code === 11000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateProfileFields(data: UpsertOrganizationFiscalProfileParams): void {
|
||||||
|
if (!/^\d{11}$/.test(data.cuit)) {
|
||||||
|
throw new Error("El CUIT debe contener 11 dígitos");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.legalName || !data.legalName.trim()) {
|
||||||
|
throw new Error("La razón social es requerida");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.pointOfSale || data.pointOfSale <= 0) {
|
||||||
|
throw new Error("El punto de venta debe ser mayor a 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsert(
|
||||||
|
data: UpsertOrganizationFiscalProfileParams
|
||||||
|
): Promise<IOrganizationFiscalProfile> {
|
||||||
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
if (!data.companyId || !data.companyId.trim()) {
|
||||||
|
throw new Error("Se requiere una organización para guardar el perfil fiscal");
|
||||||
|
}
|
||||||
|
|
||||||
|
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
this.validateProfileFields(data);
|
||||||
|
|
||||||
|
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||||
|
companyId: data.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (existingProfile) {
|
||||||
|
const updatedProfile = await this.organizationFiscalProfiles.update({
|
||||||
|
companyId: data.companyId,
|
||||||
|
cuit: data.cuit,
|
||||||
|
legalName: data.legalName.trim(),
|
||||||
|
taxCondition: data.taxCondition,
|
||||||
|
pointOfSale: data.pointOfSale,
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedProfile) {
|
||||||
|
throw new Error("No se pudo actualizar el perfil fiscal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.organizationFiscalProfiles.create({
|
||||||
|
companyId: data.companyId,
|
||||||
|
cuit: data.cuit,
|
||||||
|
legalName: data.legalName.trim(),
|
||||||
|
taxCondition: data.taxCondition,
|
||||||
|
pointOfSale: data.pointOfSale,
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isDuplicateKeyError(error)) {
|
||||||
|
const updatedProfile = await this.organizationFiscalProfiles.update({
|
||||||
|
companyId: data.companyId,
|
||||||
|
cuit: data.cuit,
|
||||||
|
legalName: data.legalName.trim(),
|
||||||
|
taxCondition: data.taxCondition,
|
||||||
|
pointOfSale: data.pointOfSale,
|
||||||
|
status: ORGANIZATION_FISCAL_PROFILE_STATUS.ACTIVE,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedProfile) {
|
||||||
|
return updatedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||||
|
companyId: data.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProfile) {
|
||||||
|
return existingProfile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async find(
|
||||||
|
data: FindOrganizationFiscalProfilesParams
|
||||||
|
): Promise<IOrganizationFiscalProfile[]> {
|
||||||
|
if (!data.sessionUser) {
|
||||||
|
throw new Error("Se requiere un usuario autenticado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionUser = data.sessionUser;
|
||||||
|
await validateSessionUser({ sessionUser });
|
||||||
|
|
||||||
|
if (data.companyId) {
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: data.companyId,
|
||||||
|
sessionUser,
|
||||||
|
});
|
||||||
|
} else if (data.id) {
|
||||||
|
const profile = await this.organizationFiscalProfiles.findOne(data);
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await validatePermissionsByCompany({
|
||||||
|
companyId: String(profile.companyId),
|
||||||
|
sessionUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [profile];
|
||||||
|
} else {
|
||||||
|
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.organizationFiscalProfiles.find(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new OrganizationFiscalProfilesManager();
|
||||||
@@ -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 { ApiError } from "@core/Models/Server.Error.model";
|
||||||
import * as Yup from "yup";
|
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({
|
const schemaCreateCashFlowMovement = Yup.object().shape({
|
||||||
paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."),
|
paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."),
|
||||||
companyId: Yup.string().required("La organización no es valida."),
|
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> => {
|
export const cashFlowPaginateBalance = async (data: FindCashFlowBalanceParams): Promise<number> => {
|
||||||
return ApiRequest.post<number>("cashflow/paginate-balance", data);
|
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 { useNavigation } from "@core/app/hooks/goto";
|
||||||
import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/CashFlow.model";
|
import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/CashFlow.model";
|
||||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
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 { useAlert } from "@core/Store/Alert.Store";
|
||||||
import { IconButton } from "@mui/material";
|
import { IconButton } from "@mui/material";
|
||||||
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
||||||
@@ -42,6 +42,10 @@ export default function OrganizationCashFlowDetail() {
|
|||||||
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
||||||
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
||||||
const [view, setView] = useState<PaginateCashFlowResults | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
@@ -73,6 +77,10 @@ export default function OrganizationCashFlowDetail() {
|
|||||||
cargarDatos();
|
cargarDatos();
|
||||||
}, [SessionInfo.userId, id, dateFrom, dateTo]);
|
}, [SessionInfo.userId, id, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadArcaLastVoucher();
|
||||||
|
}, [SessionInfo.userId, id]);
|
||||||
|
|
||||||
const cargarDatos = async () => {
|
const cargarDatos = async () => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
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) => {
|
const add = (type: CLIENT_ACCOUNT_MOVEMENT_TYPES) => {
|
||||||
goTo("/admin/org/profile/" + id + "/cash/add/" + type);
|
goTo("/admin/org/profile/" + id + "/cash/add/" + type);
|
||||||
};
|
};
|
||||||
@@ -175,6 +211,40 @@ export default function OrganizationCashFlowDetail() {
|
|||||||
>
|
>
|
||||||
{formatPrice(view.balance)}
|
{formatPrice(view.balance)}
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ReviewsIcon from '@mui/icons-material/Reviews';
|
|||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||||
|
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||||
import API from "@services/Api.Service";
|
import API from "@services/Api.Service";
|
||||||
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||||
|
|
||||||
@@ -88,6 +89,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
|
|||||||
keywords: ["link", "enlace", "url", "slug", "qr", "codigo qr", "publico", "reservas"],
|
keywords: ["link", "enlace", "url", "slug", "qr", "codigo qr", "publico", "reservas"],
|
||||||
icon: QrCode2Icon
|
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",
|
id: "notifications",
|
||||||
flowId: "notifications",
|
flowId: "notifications",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import NotificationsFlow from "./flows/NotificationsFlow";
|
|||||||
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
|
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
|
||||||
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
|
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
|
||||||
import ReservationPeriodFlow from "./flows/ReservationPeriodFlow";
|
import ReservationPeriodFlow from "./flows/ReservationPeriodFlow";
|
||||||
|
import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
|
||||||
|
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
|
||||||
import SuccessScreen from "./components/SuccessScreen";
|
import SuccessScreen from "./components/SuccessScreen";
|
||||||
import OrganizationPicker from "./components/OrganizationPicker";
|
import OrganizationPicker from "./components/OrganizationPicker";
|
||||||
import { reservationPeriodOptions } from "./constants/reservationPeriods";
|
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 [wapQr, setWapQr] = useState<string | null>(null);
|
||||||
const [wapError, setWapError] = 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
|
// State for Setup Collaborator Flow
|
||||||
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
||||||
const [setupMissingServices, setSetupMissingServices] = useState(false);
|
const [setupMissingServices, setSetupMissingServices] = useState(false);
|
||||||
@@ -392,11 +408,43 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
setWapView(null);
|
setWapView(null);
|
||||||
setWapQr(null);
|
setWapQr(null);
|
||||||
setWapError(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(() => {
|
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);
|
setIsResuming(true);
|
||||||
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
|
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
@@ -417,7 +465,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
.finally(() => setIsResuming(false));
|
.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);
|
setCurrentStepIndex(0);
|
||||||
}
|
}
|
||||||
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
|
}, [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 = () => (
|
const renderOnboardingOrgFlow = () => (
|
||||||
<OnboardingOrgFlow
|
<OnboardingOrgFlow
|
||||||
currentStepIndex={currentStepIndex}
|
currentStepIndex={currentStepIndex}
|
||||||
@@ -1678,6 +1905,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
if (flowId === "public-link") return renderPublicLinkFlow();
|
if (flowId === "public-link") return renderPublicLinkFlow();
|
||||||
if (flowId === "notifications") return renderNotificationsFlow();
|
if (flowId === "notifications") return renderNotificationsFlow();
|
||||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
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 === "disable-schedule") return renderDisableScheduleFlow();
|
||||||
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
||||||
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
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>;
|
||||||
|
}
|
||||||
+141
@@ -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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user