feat: implement ARCA integration architecture including credential, fiscal profile, and invoice models, services, and onboarding flows
This commit is contained in:
@@ -13,6 +13,19 @@ import {
|
||||
import { ApiError } from "@core/Models/Server.Error.model";
|
||||
import * as Yup from "yup";
|
||||
|
||||
export type ArcaWsfeLastVoucherParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ArcaWsfeLastVoucherResult = {
|
||||
environment: "homologation";
|
||||
service: "wsfe";
|
||||
pointOfSale: number;
|
||||
voucherType: 11;
|
||||
lastVoucherNumber: number;
|
||||
};
|
||||
|
||||
const schemaCreateCashFlowMovement = Yup.object().shape({
|
||||
paymentMethod: Yup.string().required("Debe introducir el tipo de movimiento."),
|
||||
companyId: Yup.string().required("La organización no es valida."),
|
||||
@@ -72,3 +85,9 @@ export const cashFlowPaginate = async (
|
||||
export const cashFlowPaginateBalance = async (data: FindCashFlowBalanceParams): Promise<number> => {
|
||||
return ApiRequest.post<number>("cashflow/paginate-balance", data);
|
||||
};
|
||||
|
||||
export const getArcaWsfeLastVoucher = async (
|
||||
data: ArcaWsfeLastVoucherParams
|
||||
): Promise<ArcaWsfeLastVoucherResult> => {
|
||||
return ApiRequest.post<ArcaWsfeLastVoucherResult>("arca-credentials/wsfe-last-voucher", data);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import ButtonMaterial from "@mui/material/Button";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/CashFlow.model";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { cashFlowPaginate } from "./Cash.Service";
|
||||
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { IconButton } from "@mui/material";
|
||||
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
||||
@@ -42,6 +42,10 @@ export default function OrganizationCashFlowDetail() {
|
||||
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
||||
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
||||
const [view, setView] = useState<PaginateCashFlowResults | null>(null);
|
||||
const [arcaLastVoucher, setArcaLastVoucher] = useState<ArcaWsfeLastVoucherResult | null>(null);
|
||||
const [arcaLastVoucherLoading, setArcaLastVoucherLoading] = useState(false);
|
||||
const [arcaLastVoucherError, setArcaLastVoucherError] = useState(false);
|
||||
const [arcaLastVoucherErrorMessage, setArcaLastVoucherErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -73,6 +77,10 @@ export default function OrganizationCashFlowDetail() {
|
||||
cargarDatos();
|
||||
}, [SessionInfo.userId, id, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
loadArcaLastVoucher();
|
||||
}, [SessionInfo.userId, id]);
|
||||
|
||||
const cargarDatos = async () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
@@ -110,6 +118,34 @@ export default function OrganizationCashFlowDetail() {
|
||||
});
|
||||
};
|
||||
|
||||
const loadArcaLastVoucher = async () => {
|
||||
if (!SessionInfo.userId || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setArcaLastVoucherLoading(true);
|
||||
setArcaLastVoucherError(false);
|
||||
setArcaLastVoucherErrorMessage("");
|
||||
|
||||
getArcaWsfeLastVoucher({
|
||||
companyId: id,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((data) => {
|
||||
setArcaLastVoucher(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
setArcaLastVoucher(null);
|
||||
setArcaLastVoucherError(true);
|
||||
setArcaLastVoucherErrorMessage(
|
||||
error?.desc || error?.message || "No pudimos consultar ARCA"
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setArcaLastVoucherLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const add = (type: CLIENT_ACCOUNT_MOVEMENT_TYPES) => {
|
||||
goTo("/admin/org/profile/" + id + "/cash/add/" + type);
|
||||
};
|
||||
@@ -175,6 +211,40 @@ export default function OrganizationCashFlowDetail() {
|
||||
>
|
||||
{formatPrice(view.balance)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "10px",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "var(--white)",
|
||||
color: arcaLastVoucherError
|
||||
? "var(--gray-dark)"
|
||||
: "var(--wine-dark)",
|
||||
fontSize: "13px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{arcaLastVoucherLoading ? (
|
||||
"Consultando último comprobante ARCA..."
|
||||
) : arcaLastVoucher ? (
|
||||
<>
|
||||
Última Factura C autorizada ARCA: #{" "}
|
||||
<strong>{arcaLastVoucher.lastVoucherNumber}</strong>
|
||||
{" · "}PV {arcaLastVoucher.pointOfSale}
|
||||
</>
|
||||
) : arcaLastVoucherError ? (
|
||||
<>
|
||||
No pudimos consultar ARCA
|
||||
{arcaLastVoucherErrorMessage && (
|
||||
<div style={{ marginTop: "4px" }}>
|
||||
{arcaLastVoucherErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"ARCA: esperando consulta del último comprobante"
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
||||
@@ -19,6 +19,7 @@ import ReviewsIcon from '@mui/icons-material/Reviews';
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||
import API from "@services/Api.Service";
|
||||
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||
|
||||
@@ -88,6 +89,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
|
||||
keywords: ["link", "enlace", "url", "slug", "qr", "codigo qr", "publico", "reservas"],
|
||||
icon: QrCode2Icon
|
||||
},
|
||||
{
|
||||
id: "arca-onboarding",
|
||||
flowId: "arca-onboarding",
|
||||
title: "Conectar ARCA",
|
||||
description: "Configurá los datos fiscales y el certificado para emitir Factura C.",
|
||||
category: "Integraciones",
|
||||
keywords: ["arca", "afip", "factura", "factura c", "fiscal", "certificado", "csr", "monotributo"],
|
||||
icon: ReceiptLongIcon
|
||||
},
|
||||
{
|
||||
id: "arca-test-connection",
|
||||
flowId: "arca-test-connection",
|
||||
title: "Probar conexión ARCA",
|
||||
description: "Validá el certificado y la conexión con el servicio WSFE de ARCA.",
|
||||
category: "Integraciones",
|
||||
keywords: ["arca", "afip", "wsaa", "wsfe", "conexion", "certificado", "factura"],
|
||||
icon: ReceiptLongIcon
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
flowId: "notifications",
|
||||
|
||||
@@ -27,6 +27,8 @@ import NotificationsFlow from "./flows/NotificationsFlow";
|
||||
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
|
||||
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
|
||||
import ReservationPeriodFlow from "./flows/ReservationPeriodFlow";
|
||||
import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
|
||||
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
|
||||
import SuccessScreen from "./components/SuccessScreen";
|
||||
import OrganizationPicker from "./components/OrganizationPicker";
|
||||
import { reservationPeriodOptions } from "./constants/reservationPeriods";
|
||||
@@ -178,6 +180,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [wapQr, setWapQr] = useState<string | null>(null);
|
||||
const [wapError, setWapError] = useState<string | null>(null);
|
||||
|
||||
// State for ARCA onboarding flow
|
||||
const [arcaCuit, setArcaCuit] = useState("");
|
||||
const [arcaLegalName, setArcaLegalName] = useState("");
|
||||
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
|
||||
const [arcaPointOfSale, setArcaPointOfSale] = useState("1");
|
||||
const [arcaCsrPem, setArcaCsrPem] = useState("");
|
||||
const [arcaCertificatePem, setArcaCertificatePem] = useState("");
|
||||
const [arcaStatusLabel, setArcaStatusLabel] = useState("");
|
||||
const [arcaLoading, setArcaLoading] = useState(false);
|
||||
const [arcaDiagnosticsLoading, setArcaDiagnosticsLoading] = useState(false);
|
||||
const [arcaError, setArcaError] = useState<string | null>(null);
|
||||
const [arcaTestResult, setArcaTestResult] = useState<ArcaWsaaTestResult | null>(null);
|
||||
const [arcaDiagnosticsResult, setArcaDiagnosticsResult] = useState<ArcaWsfeDiagnosticsResult | null>(null);
|
||||
|
||||
// State for Setup Collaborator Flow
|
||||
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
|
||||
const [setupMissingServices, setSetupMissingServices] = useState(false);
|
||||
@@ -392,11 +408,43 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setWapView(null);
|
||||
setWapQr(null);
|
||||
setWapError(null);
|
||||
} else if (flowId === "arca-onboarding") {
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
setArcaTestResult(null);
|
||||
setArcaCertificatePem("");
|
||||
API.post<any[]>("organization-fiscal-profiles/find", {
|
||||
companyId: org.id || org._id,
|
||||
sessionUser: SessionInfo.userId
|
||||
}).then(res => {
|
||||
const profile = res?.[0];
|
||||
if (!profile) return;
|
||||
setArcaCuit(profile.cuit || "");
|
||||
setArcaLegalName(profile.legalName || "");
|
||||
setArcaTaxCondition(profile.taxCondition || "MONOTRIBUTO");
|
||||
setArcaPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
|
||||
}).catch(console.error);
|
||||
API.post<any[]>("arca-credentials/find", {
|
||||
companyId: org.id || org._id,
|
||||
sessionUser: SessionInfo.userId
|
||||
}).then(res => {
|
||||
const credential = res?.[0];
|
||||
if (!credential) return;
|
||||
setArcaCsrPem(credential.csrPem || "");
|
||||
setArcaStatusLabel(getArcaStatusLabel(credential.status));
|
||||
}).catch(console.error);
|
||||
} else if (flowId === "arca-test-connection") {
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
setArcaTestResult(null);
|
||||
setArcaDiagnosticsResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
setIsResuming(true);
|
||||
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
|
||||
.then(response => {
|
||||
@@ -417,7 +465,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
.finally(() => setIsResuming(false));
|
||||
}
|
||||
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
setCurrentStepIndex(0);
|
||||
}
|
||||
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
|
||||
@@ -758,6 +806,185 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
/>
|
||||
);
|
||||
|
||||
const getArcaStatusLabel = (status?: string) => {
|
||||
if (status === "READY") return "Certificado validado. La conexión con ARCA quedó lista.";
|
||||
if (status === "CERTIFICATE_UPLOADED") return "Certificado cargado. Estamos listos para validar la conexión.";
|
||||
if (status === "CSR_GENERATED") return "CSR generado. Descargalo y subilo en ARCA para obtener el certificado.";
|
||||
if (status === "INVALID") return "El certificado cargado no pudo validarse. Revisá que corresponda al CSR generado.";
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleSaveArcaFiscalProfile = async () => {
|
||||
if (!createdCompanyId || arcaLoading) return;
|
||||
setArcaLoading(true);
|
||||
setArcaError(null);
|
||||
try {
|
||||
await API.post<any>("organization-fiscal-profiles/upsert", {
|
||||
companyId: createdCompanyId,
|
||||
cuit: arcaCuit,
|
||||
legalName: arcaLegalName,
|
||||
taxCondition: arcaTaxCondition,
|
||||
pointOfSale: Number(arcaPointOfSale),
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
handleNext();
|
||||
} catch (error: any) {
|
||||
console.error("Error guardando datos fiscales ARCA:", error);
|
||||
setArcaError(error?.message || "No pudimos guardar los datos fiscales. Revisá la información y volvé a intentar.");
|
||||
} finally {
|
||||
setArcaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateArcaCsr = async () => {
|
||||
if (!createdCompanyId || arcaLoading) return;
|
||||
setArcaLoading(true);
|
||||
setArcaError(null);
|
||||
try {
|
||||
const credential = await API.post<any>("arca-credentials/generate-csr", {
|
||||
companyId: createdCompanyId,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
const generatedCsr = credential?.csrPem || "";
|
||||
setArcaCsrPem(generatedCsr);
|
||||
setArcaStatusLabel(getArcaStatusLabel(credential?.status));
|
||||
if (generatedCsr) {
|
||||
downloadArcaCsr(generatedCsr);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error generando CSR ARCA:", error);
|
||||
setArcaError(error?.message || "No pudimos generar el CSR. Intentá nuevamente.");
|
||||
} finally {
|
||||
setArcaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadArcaCsr = (csrPem: string) => {
|
||||
const blob = new Blob([csrPem], { type: "application/pkcs10" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `turnosxpress-arca-${arcaCuit || createdCompanyId}.csr`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDownloadArcaCsr = () => {
|
||||
if (!arcaCsrPem) return;
|
||||
downloadArcaCsr(arcaCsrPem);
|
||||
};
|
||||
|
||||
const handleArcaCertificateFile = (file: File | null) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setArcaCertificatePem(String(reader.result || ""));
|
||||
reader.onerror = () => setArcaError("No pudimos leer el archivo. Probá pegando el contenido del certificado.");
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleUploadArcaCertificate = async () => {
|
||||
if (!createdCompanyId || arcaLoading || !arcaCertificatePem.trim()) return;
|
||||
setArcaLoading(true);
|
||||
setArcaError(null);
|
||||
try {
|
||||
const credential = await API.post<any>("arca-credentials/upload-certificate", {
|
||||
companyId: createdCompanyId,
|
||||
certificatePem: arcaCertificatePem,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
setArcaStatusLabel(getArcaStatusLabel(credential?.status));
|
||||
setCurrentStepIndex(5);
|
||||
} catch (error: any) {
|
||||
console.error("Error cargando certificado ARCA:", error);
|
||||
setArcaError(error?.message || "No pudimos guardar el certificado. Revisá que sea el archivo emitido por ARCA.");
|
||||
} finally {
|
||||
setArcaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestArcaConnection = async () => {
|
||||
if (!createdCompanyId || arcaLoading) return;
|
||||
setArcaLoading(true);
|
||||
setArcaError(null);
|
||||
setArcaTestResult(null);
|
||||
setArcaDiagnosticsResult(null);
|
||||
try {
|
||||
const result = await API.post<ArcaWsaaTestResult>("arca-credentials/test-wsaa-login", {
|
||||
companyId: createdCompanyId,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
setArcaTestResult(result);
|
||||
setCurrentStepIndex(1);
|
||||
setArcaDiagnosticsLoading(true);
|
||||
try {
|
||||
const diagnostics = await API.post<ArcaWsfeDiagnosticsResult>("arca-credentials/wsfe-diagnostics", {
|
||||
companyId: createdCompanyId,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
setArcaDiagnosticsResult(diagnostics);
|
||||
} catch (diagnosticsError: any) {
|
||||
console.error("Error consultando diagnóstico WSFE ARCA:", diagnosticsError);
|
||||
setArcaDiagnosticsResult({
|
||||
dummy: { ok: false, error: diagnosticsError?.message || "No pudimos consultar WSFE." },
|
||||
voucherTypes: { ok: false, error: diagnosticsError?.message || "No pudimos consultar tipos de comprobante." },
|
||||
pointsOfSale: { ok: false, error: diagnosticsError?.message || "No pudimos consultar puntos de venta." },
|
||||
lastVoucher: { ok: false, error: diagnosticsError?.message || "No pudimos consultar el último comprobante." }
|
||||
});
|
||||
} finally {
|
||||
setArcaDiagnosticsLoading(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error probando conexión ARCA:", error);
|
||||
setArcaError(error?.message || "No pudimos validar la conexión con ARCA. Revisá el certificado y volvé a intentar.");
|
||||
setCurrentStepIndex(1);
|
||||
} finally {
|
||||
setArcaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderArcaTestConnectionFlow = () => (
|
||||
<ArcaTestConnectionFlow
|
||||
currentStepIndex={currentStepIndex}
|
||||
loading={arcaLoading}
|
||||
diagnosticsLoading={arcaDiagnosticsLoading}
|
||||
error={arcaError}
|
||||
result={arcaTestResult}
|
||||
diagnostics={arcaDiagnosticsResult}
|
||||
handleTestConnection={handleTestArcaConnection}
|
||||
handleFinish={handleActionSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderArcaOnboardingFlow = () => (
|
||||
<ArcaOnboardingFlow
|
||||
currentStepIndex={currentStepIndex}
|
||||
cuit={arcaCuit}
|
||||
setCuit={setArcaCuit}
|
||||
legalName={arcaLegalName}
|
||||
setLegalName={setArcaLegalName}
|
||||
taxCondition={arcaTaxCondition}
|
||||
setTaxCondition={setArcaTaxCondition}
|
||||
pointOfSale={arcaPointOfSale}
|
||||
setPointOfSale={setArcaPointOfSale}
|
||||
csrPem={arcaCsrPem}
|
||||
certificatePem={arcaCertificatePem}
|
||||
setCertificatePem={setArcaCertificatePem}
|
||||
statusLabel={arcaStatusLabel}
|
||||
arcaLoading={arcaLoading}
|
||||
arcaError={arcaError}
|
||||
handleNext={handleNext}
|
||||
handleSaveFiscalProfile={handleSaveArcaFiscalProfile}
|
||||
handleGenerateCsr={handleGenerateArcaCsr}
|
||||
handleDownloadCsr={handleDownloadArcaCsr}
|
||||
handleCertificateFile={handleArcaCertificateFile}
|
||||
handleUploadCertificate={handleUploadArcaCertificate}
|
||||
handleEditFiscalProfile={() => setCurrentStepIndex(1)}
|
||||
handleFinish={handleActionSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderOnboardingOrgFlow = () => (
|
||||
<OnboardingOrgFlow
|
||||
currentStepIndex={currentStepIndex}
|
||||
@@ -1678,6 +1905,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
if (flowId === "public-link") return renderPublicLinkFlow();
|
||||
if (flowId === "notifications") return renderNotificationsFlow();
|
||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
||||
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow();
|
||||
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow();
|
||||
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
|
||||
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
||||
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useState } from "react";
|
||||
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
|
||||
import QuestionCard from "../../QuestionCard/QuestionCard";
|
||||
import QuestionInput from "../../QuestionCard/QuestionInput";
|
||||
|
||||
type ArcaTaxCondition = "MONOTRIBUTO" | "EXENTO";
|
||||
|
||||
type Props = {
|
||||
currentStepIndex: number;
|
||||
cuit: string;
|
||||
setCuit: (value: string) => void;
|
||||
legalName: string;
|
||||
setLegalName: (value: string) => void;
|
||||
taxCondition: ArcaTaxCondition;
|
||||
setTaxCondition: (value: ArcaTaxCondition) => void;
|
||||
pointOfSale: string;
|
||||
setPointOfSale: (value: string) => void;
|
||||
csrPem: string;
|
||||
certificatePem: string;
|
||||
setCertificatePem: (value: string) => void;
|
||||
statusLabel: string;
|
||||
arcaLoading: boolean;
|
||||
arcaError: string | null;
|
||||
handleNext: () => void;
|
||||
handleSaveFiscalProfile: () => void;
|
||||
handleGenerateCsr: () => void;
|
||||
handleDownloadCsr: () => void;
|
||||
handleCertificateFile: (file: File | null) => void;
|
||||
handleUploadCertificate: () => void;
|
||||
handleEditFiscalProfile: () => void;
|
||||
handleFinish: () => void;
|
||||
};
|
||||
|
||||
export default function ArcaOnboardingFlow(props: Props) {
|
||||
const { currentStepIndex, cuit, setCuit, legalName, setLegalName, taxCondition, setTaxCondition, pointOfSale, setPointOfSale, csrPem, certificatePem, setCertificatePem, statusLabel, arcaLoading, arcaError, handleNext, handleSaveFiscalProfile, handleGenerateCsr, handleDownloadCsr, handleCertificateFile, handleUploadCertificate, handleEditFiscalProfile, handleFinish } = props;
|
||||
const canSaveFiscalProfile = cuit.trim().length >= 11 && legalName.trim().length >= 3 && Number(pointOfSale) > 0;
|
||||
const [csrCopyStatus, setCsrCopyStatus] = useState<string | null>(null);
|
||||
|
||||
const handleCopyCsr = async () => {
|
||||
if (!csrPem) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(csrPem);
|
||||
setCsrCopyStatus("CSR copiado. Pegalo en ARCA cuando te lo pida.");
|
||||
} catch (error) {
|
||||
setCsrCopyStatus("No pudimos copiarlo automáticamente. Seleccioná el texto de abajo y copialo manualmente.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuestionCard isActive={currentStepIndex === 0} title="Conectar ARCA" description="Te voy a guiar para dejar lista la emisión de Factura C. Primero guardamos tus datos fiscales, después generamos el pedido de certificado y al final cargás el certificado que te entrega ARCA." onNext={handleNext} nextLabel="Empezar" topAccessory={<ReceiptLongIcon style={{ fontSize: 52, color: "var(--wine-red)" }} />}>
|
||||
<InfoPanel>Vas a necesitar tener acceso a ARCA con clave fiscal y permisos para administrar certificados del servicio de facturación electrónica.</InfoPanel>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === 1} title="Datos fiscales" description="Completá los datos que van a identificar a tu organización cuando emitas Factura C." onNext={handleSaveFiscalProfile} disableNext={!canSaveFiscalProfile || arcaLoading} nextLabel={arcaLoading ? "Guardando..." : "Guardar datos fiscales"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
|
||||
<QuestionInput label="CUIT" value={cuit} onChange={(event) => setCuit(event.target.value.replace(/\D/g, ""))} placeholder="Sin guiones, por ejemplo 20123456789" inputMode="numeric" />
|
||||
<QuestionInput label="Razón social" value={legalName} onChange={(event) => setLegalName(event.target.value)} placeholder="Nombre fiscal o razón social" />
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "10px" }}>
|
||||
<OptionButton active={taxCondition === "MONOTRIBUTO"} onClick={() => setTaxCondition("MONOTRIBUTO")}>Monotributo</OptionButton>
|
||||
<OptionButton active={taxCondition === "EXENTO"} onClick={() => setTaxCondition("EXENTO")}>Exento</OptionButton>
|
||||
</div>
|
||||
<QuestionInput label="Punto de venta" value={pointOfSale} onChange={(event) => setPointOfSale(event.target.value.replace(/\D/g, ""))} placeholder="Ejemplo: 1" inputMode="numeric" />
|
||||
<StatusMessage error={arcaError} />
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === 2} title="Pedido de certificado" description="Ahora generamos el archivo CSR. Ese archivo se descarga desde TurnosXpress y luego se sube en ARCA para que te devuelvan el certificado." onNext={csrPem ? handleNext : handleGenerateCsr} disableNext={arcaLoading} nextLabel={csrPem ? "Continuar con ARCA" : arcaLoading ? "Generando..." : "Generar y descargar CSR"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<InfoPanel>{statusLabel || "Todavía no generamos el CSR para esta organización."}</InfoPanel>
|
||||
{csrPem && <InfoPanel>Ya generamos el CSR. ARCA puede pedirte subir el archivo o pegar el contenido completo, incluyendo BEGIN y END CERTIFICATE REQUEST.</InfoPanel>}
|
||||
{csrPem && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<textarea value={csrPem} readOnly rows={9} aria-label="Contenido CSR para copiar en ARCA" onFocus={(event) => event.target.select()} style={{ width: "100%", boxSizing: "border-box", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", padding: "14px", fontFamily: "monospace", fontSize: "12px", lineHeight: 1.4, resize: "vertical" }} />
|
||||
{csrCopyStatus && <InfoPanel>{csrCopyStatus}</InfoPanel>}
|
||||
<SecondaryButton onClick={handleCopyCsr}>Copiar CSR</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{csrPem && <SecondaryButton onClick={handleDownloadCsr}>Descargar archivo .csr</SecondaryButton>}
|
||||
{csrPem && <SecondaryButton onClick={handleGenerateCsr}>Regenerar CSR</SecondaryButton>}
|
||||
<StatusMessage error={arcaError} />
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === 3} title="Subir el CSR en ARCA" description="Con el archivo descargado, entrá a ARCA y generá el certificado para facturación electrónica." onNext={handleNext} nextLabel="Ya tengo el certificado">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<StepItem number="1">Ingresá a ARCA con clave fiscal y abrí el administrador de certificados digitales.</StepItem>
|
||||
<StepItem number="2">Creá un nuevo certificado y subí el archivo .csr que descargaste desde TurnosXpress.</StepItem>
|
||||
<StepItem number="3">Asociá el certificado al servicio de facturación electrónica WSFE.</StepItem>
|
||||
<StepItem number="4">Descargá el certificado emitido por ARCA en formato .crt o .pem.</StepItem>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === 4} title="Cargar certificado" description="Pegá el contenido del certificado o seleccioná el archivo que descargaste desde ARCA." onNext={handleUploadCertificate} disableNext={!certificatePem.trim() || arcaLoading} nextLabel={arcaLoading ? "Validando..." : "Guardar certificado"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<input type="file" accept=".crt,.pem,text/plain" onChange={(event) => handleCertificateFile(event.target.files?.[0] || null)} style={{ color: "rgba(255,255,255,0.75)" }} />
|
||||
<textarea value={certificatePem} onChange={(event) => setCertificatePem(event.target.value)} placeholder="-----BEGIN CERTIFICATE-----" rows={8} style={{ width: "100%", boxSizing: "border-box", borderRadius: "12px", border: "1px solid rgba(255,255,255,0.16)", background: "rgba(255,255,255,0.05)", color: "white", padding: "14px", fontFamily: "monospace", resize: "vertical" }} />
|
||||
<StatusMessage error={arcaError} />
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === 5} title="ARCA conectado" description="Tu organización ya tiene los datos fiscales y el certificado guardados para avanzar con Factura C." onNext={handleFinish} nextLabel="Finalizar" topAccessory={<div style={{ fontSize: 48 }}>✅</div>}>
|
||||
<InfoPanel>{statusLabel || "Configuración lista."}</InfoPanel>
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<SecondaryButton onClick={handleEditFiscalProfile}>Editar datos fiscales</SecondaryButton>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoPanel({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.75)", fontSize: "14px", lineHeight: 1.5 }}>{children}</div>;
|
||||
}
|
||||
|
||||
function OptionButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return <button type="button" onClick={onClick} style={{ padding: "12px 16px", borderRadius: "10px", border: active ? "1px solid var(--wine-red)" : "1px solid rgba(255,255,255,0.16)", background: active ? "rgba(255,42,127,0.16)" : "rgba(255,255,255,0.05)", color: "white", cursor: "pointer", fontWeight: 700, fontFamily: "inherit" }}>{children}</button>;
|
||||
}
|
||||
|
||||
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return <button type="button" onClick={onClick} style={{ padding: "12px 16px", borderRadius: "10px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.16)", color: "white", cursor: "pointer", fontWeight: 700, fontFamily: "inherit" }}>{children}</button>;
|
||||
}
|
||||
|
||||
function StepItem({ number, children }: { number: string; children: React.ReactNode }) {
|
||||
return <div style={{ display: "flex", gap: "12px", alignItems: "flex-start", padding: "14px", borderRadius: "12px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.78)", lineHeight: 1.5 }}><strong style={{ color: "white" }}>{number}.</strong><span>{children}</span></div>;
|
||||
}
|
||||
|
||||
function StatusMessage({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return <div style={{ padding: "12px 14px", borderRadius: "10px", background: "rgba(255,60,60,0.1)", border: "1px solid rgba(255,60,60,0.3)", color: "#ff9999", fontSize: "13px", lineHeight: 1.4 }}>{error}</div>;
|
||||
}
|
||||
+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