Compare commits
2 Commits
1c92b8eaf1
...
e8aaf9ff15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8aaf9ff15 | ||
|
|
44d7c796c9 |
@@ -122,7 +122,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
|
|||||||
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
// No bloquea la respuesta ni falla la creación si SMTP no está configurado.
|
||||||
notifyDocumentoSubido({
|
notifyDocumentoSubido({
|
||||||
pool: req.tenantPool!,
|
pool: req.tenantPool!,
|
||||||
tenantId: req.user!.tenantId,
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
contribuyenteId: contribuyenteId ?? null,
|
contribuyenteId: contribuyenteId ?? null,
|
||||||
subidoPor: req.user!.email,
|
subidoPor: req.user!.email,
|
||||||
kind: 'declaracion',
|
kind: 'declaracion',
|
||||||
@@ -283,7 +283,7 @@ export async function crearExtra(req: Request, res: Response, next: NextFunction
|
|||||||
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
// Notificación fire-and-forget a owners del despacho + supervisor del RFC.
|
||||||
notifyDocumentoSubido({
|
notifyDocumentoSubido({
|
||||||
pool: req.tenantPool!,
|
pool: req.tenantPool!,
|
||||||
tenantId: req.user!.tenantId,
|
tenantId: req.viewingTenantId ?? req.user!.tenantId,
|
||||||
contribuyenteId: contribuyenteId ?? null,
|
contribuyenteId: contribuyenteId ?? null,
|
||||||
subidoPor: req.user!.email,
|
subidoPor: req.user!.email,
|
||||||
kind: 'extra',
|
kind: 'extra',
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
await requireGlobalAdmin(req);
|
await requireGlobalAdmin(req);
|
||||||
|
|
||||||
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt } = req.body;
|
const { nombre, rfc, plan, adminEmail, adminNombre, amount, firstPaymentDueAt, verticalProfile, codigoPostal } = req.body;
|
||||||
|
|
||||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||||
@@ -66,6 +66,8 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
adminNombre,
|
adminNombre,
|
||||||
amount: amount || 0,
|
amount: amount || 0,
|
||||||
firstPaymentDueAt: firstPaymentDueAt || null,
|
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||||
|
verticalProfile: verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(result);
|
res.status(201).json(result);
|
||||||
|
|||||||
@@ -43,16 +43,19 @@ async function getTenantsWithFiel(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica si un tenant necesita sincronización inicial
|
* Verifica si un tenant (o un contribuyente específico dentro del tenant)
|
||||||
|
* necesita sincronización inicial.
|
||||||
*/
|
*/
|
||||||
async function needsInitialSync(tenantId: string): Promise<boolean> {
|
async function needsInitialSync(tenantId: string, contribuyenteId?: string): Promise<boolean> {
|
||||||
const completedSync = await prisma.satSyncJob.findFirst({
|
const where: any = {
|
||||||
where: {
|
tenantId,
|
||||||
tenantId,
|
type: 'initial',
|
||||||
type: 'initial',
|
status: 'completed',
|
||||||
status: 'completed',
|
};
|
||||||
},
|
if (contribuyenteId) {
|
||||||
});
|
where.contribuyenteId = contribuyenteId;
|
||||||
|
}
|
||||||
|
const completedSync = await prisma.satSyncJob.findFirst({ where });
|
||||||
|
|
||||||
return !completedSync;
|
return !completedSync;
|
||||||
}
|
}
|
||||||
@@ -62,10 +65,6 @@ async function needsInitialSync(tenantId: string): Promise<boolean> {
|
|||||||
*/
|
*/
|
||||||
async function syncTenant(tenantId: string): Promise<void> {
|
async function syncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Determinar tipo de sync
|
|
||||||
const needsInitial = await needsInitialSync(tenantId);
|
|
||||||
const syncType = needsInitial ? 'initial' : 'daily';
|
|
||||||
|
|
||||||
// Obtener contribuyentes del tenant
|
// Obtener contribuyentes del tenant
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
@@ -81,6 +80,8 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||||
if (contribuyenteIds.length === 0) {
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const needsInitial = await needsInitialSync(tenantId);
|
||||||
|
const syncType = needsInitial ? 'initial' : 'daily';
|
||||||
const status = await getSyncStatus(tenantId);
|
const status = await getSyncStatus(tenantId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
|
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
|
||||||
@@ -92,7 +93,7 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizar cada contribuyente
|
// Sincronizar cada contribuyente (cada uno puede necesitar su propio initial)
|
||||||
for (const contribuyenteId of contribuyenteIds) {
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
try {
|
try {
|
||||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
@@ -101,6 +102,8 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
|
||||||
|
const syncType = needsInitial ? 'initial' : 'daily';
|
||||||
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
||||||
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
|
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
|
||||||
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId} contribuyente ${contribuyenteId}`);
|
||||||
@@ -187,14 +190,6 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
|
|||||||
*/
|
*/
|
||||||
async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const completedInitial = await prisma.satSyncJob.findFirst({
|
|
||||||
where: { tenantId, type: 'initial', status: 'completed' },
|
|
||||||
});
|
|
||||||
if (!completedInitial) {
|
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtener contribuyentes del tenant
|
// Obtener contribuyentes del tenant
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
@@ -210,6 +205,13 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||||
if (contribuyenteIds.length === 0) {
|
if (contribuyenteIds.length === 0) {
|
||||||
|
const completedInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!completedInitial) {
|
||||||
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} sin sync inicial completado, omitiendo incremental`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const status = await getSyncStatus(tenantId);
|
const status = await getSyncStatus(tenantId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} con sync activo, omitiendo`);
|
||||||
@@ -221,9 +223,17 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizar cada contribuyente
|
// Sincronizar cada contribuyente solo si ya tiene su initial completado
|
||||||
for (const contribuyenteId of contribuyenteIds) {
|
for (const contribuyenteId of contribuyenteIds) {
|
||||||
try {
|
try {
|
||||||
|
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||||
|
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||||
|
});
|
||||||
|
if (!hasInitial) {
|
||||||
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} sin sync inicial, omitiendo incremental`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||||
if (status.hasActiveSync) {
|
if (status.hasActiveSync) {
|
||||||
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId} contribuyente ${contribuyenteId} con sync activo, omitiendo`);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const resultPromise = (async () => {
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ export async function consultarConstanciaContribuyente(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const resultPromise = (async () => {
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export async function createOrgContribuyente(
|
|||||||
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
||||||
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
||||||
await ensureLiveKeyCached(pool, existingId);
|
await ensureLiveKeyCached(pool, existingId);
|
||||||
|
// Backfill: si la org fue creada antes de este fix, sincronizar datos fiscales.
|
||||||
|
await updateOrgLegalOnCreate(pool, existingId, contribuyenteId);
|
||||||
return { orgId: existingId, reused: true };
|
return { orgId: existingId, reused: true };
|
||||||
} catch {
|
} catch {
|
||||||
const org = await client.organizations.create({ name: nombre });
|
const org = await client.organizations.create({ name: nombre });
|
||||||
@@ -101,6 +103,7 @@ export async function createOrgContribuyente(
|
|||||||
);
|
);
|
||||||
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
||||||
await ensureLiveKeyCached(pool, org.id);
|
await ensureLiveKeyCached(pool, org.id);
|
||||||
|
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
|
||||||
return { orgId: org.id, recreated: true };
|
return { orgId: org.id, recreated: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +116,7 @@ export async function createOrgContribuyente(
|
|||||||
);
|
);
|
||||||
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
||||||
await ensureLiveKeyCached(pool, org.id);
|
await ensureLiveKeyCached(pool, org.id);
|
||||||
|
await updateOrgLegalOnCreate(pool, org.id, contribuyenteId);
|
||||||
return { orgId: org.id };
|
return { orgId: org.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +136,98 @@ async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
|
|||||||
await persistEncryptedKey(pool, orgId, apiKey);
|
await persistEncryptedKey(pool, orgId, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ContribuyenteFiscalData {
|
||||||
|
rfc: string;
|
||||||
|
razon_social: string | null;
|
||||||
|
regimen_fiscal: string | null;
|
||||||
|
codigo_postal: string | null;
|
||||||
|
domicilio: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContribuyenteFiscalData(
|
||||||
|
pool: Pool,
|
||||||
|
contribuyenteId: string,
|
||||||
|
): Promise<ContribuyenteFiscalData> {
|
||||||
|
const { rows } = await pool.query<ContribuyenteFiscalData>(
|
||||||
|
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
||||||
|
FROM contribuyentes c
|
||||||
|
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||||
|
WHERE c.entidad_id = $1`,
|
||||||
|
[contribuyenteId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLegalPayload(
|
||||||
|
contrib: ContribuyenteFiscalData,
|
||||||
|
chosenTaxSystem: string,
|
||||||
|
currentLegal?: any,
|
||||||
|
) {
|
||||||
|
const domicilio = (contrib.domicilio || {}) as any;
|
||||||
|
return {
|
||||||
|
name: contrib.razon_social || currentLegal?.name || '',
|
||||||
|
legal_name: contrib.razon_social || currentLegal?.legal_name || '',
|
||||||
|
tax_system: chosenTaxSystem,
|
||||||
|
address: {
|
||||||
|
street: domicilio.calle || currentLegal?.address?.street || '',
|
||||||
|
exterior: domicilio.numExterior || currentLegal?.address?.exterior || '',
|
||||||
|
interior: domicilio.numInterior || currentLegal?.address?.interior || '',
|
||||||
|
neighborhood: domicilio.colonia || currentLegal?.address?.neighborhood || '',
|
||||||
|
city: domicilio.ciudad || currentLegal?.address?.city || '',
|
||||||
|
municipality: domicilio.municipio || currentLegal?.address?.municipality || '',
|
||||||
|
state: domicilio.estado || currentLegal?.address?.state || '',
|
||||||
|
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal?.address?.zip || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putOrgLegal(orgId: string, payload: any): Promise<void> {
|
||||||
|
const userKey = env.FACTURAPI_USER_KEY;
|
||||||
|
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||||
|
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${userKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!putRes.ok) {
|
||||||
|
const errText = await putRes.text();
|
||||||
|
throw new Error(
|
||||||
|
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza los datos fiscales de una organización Facturapi recién creada
|
||||||
|
* usando la información del contribuyente. Se usa el primer régimen fiscal
|
||||||
|
* registrado. No-op si no hay régimen fiscal o razón social que setear.
|
||||||
|
*/
|
||||||
|
async function updateOrgLegalOnCreate(
|
||||||
|
pool: Pool,
|
||||||
|
orgId: string,
|
||||||
|
contribuyenteId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
|
||||||
|
const allowed = (contrib.regimen_fiscal || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!allowed.length || !contrib.razon_social) {
|
||||||
|
// Datos incompletos: no fallar la creación de la org, solo loguear silenciosamente.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await buildLegalPayload(contrib, allowed[0]);
|
||||||
|
await putOrgLegal(orgId, payload);
|
||||||
|
} catch {
|
||||||
|
// No bloquear la creación de la org si el update legal falla.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrgStatusContribuyente(
|
export async function getOrgStatusContribuyente(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
contribuyenteId: string
|
contribuyenteId: string
|
||||||
@@ -409,22 +505,7 @@ async function ensureOrgLegalForEmit(
|
|||||||
const userKey = env.FACTURAPI_USER_KEY;
|
const userKey = env.FACTURAPI_USER_KEY;
|
||||||
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||||
|
|
||||||
// Datos fiscales del contribuyente (razón social + domicilio)
|
const contrib = await fetchContribuyenteFiscalData(pool, contribuyenteId);
|
||||||
const { rows } = await pool.query<{
|
|
||||||
rfc: string;
|
|
||||||
razon_social: string | null;
|
|
||||||
regimen_fiscal: string | null;
|
|
||||||
codigo_postal: string | null;
|
|
||||||
domicilio: any;
|
|
||||||
}>(
|
|
||||||
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
|
||||||
FROM contribuyentes c
|
|
||||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
|
||||||
WHERE c.entidad_id = $1`,
|
|
||||||
[contribuyenteId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
|
|
||||||
const contrib = rows[0];
|
|
||||||
|
|
||||||
// Validar que el régimen elegido esté entre los registrados del contrib
|
// Validar que el régimen elegido esté entre los registrados del contrib
|
||||||
const allowed = (contrib.regimen_fiscal || '')
|
const allowed = (contrib.regimen_fiscal || '')
|
||||||
@@ -458,36 +539,6 @@ async function ensureOrgLegalForEmit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const domicilio = (contrib.domicilio || {}) as any;
|
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
|
||||||
const legalPayload = {
|
await putOrgLegal(orgId, payload);
|
||||||
name: contrib.razon_social || currentLegal.name || '',
|
|
||||||
legal_name: contrib.razon_social || currentLegal.legal_name || '',
|
|
||||||
tax_system: chosenTaxSystem,
|
|
||||||
address: {
|
|
||||||
street: domicilio.calle || currentLegal.address?.street || '',
|
|
||||||
exterior: domicilio.numExterior || currentLegal.address?.exterior || '',
|
|
||||||
interior: domicilio.numInterior || currentLegal.address?.interior || '',
|
|
||||||
neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '',
|
|
||||||
city: domicilio.ciudad || currentLegal.address?.city || '',
|
|
||||||
municipality: domicilio.municipio || currentLegal.address?.municipality || '',
|
|
||||||
state: domicilio.estado || currentLegal.address?.state || '',
|
|
||||||
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${userKey}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(legalPayload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!putRes.ok) {
|
|
||||||
const errText = await putRes.text();
|
|
||||||
throw new Error(
|
|
||||||
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function loginSatCsf(
|
|||||||
cerPath: string,
|
cerPath: string,
|
||||||
keyPath: string,
|
keyPath: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
knownRfc?: string,
|
||||||
): Promise<CsfLoginSession> {
|
): Promise<CsfLoginSession> {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
acceptDownloads: true,
|
acceptDownloads: true,
|
||||||
@@ -73,6 +74,7 @@ export async function loginSatCsf(
|
|||||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||||
|
|
||||||
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
||||||
|
let rfcPopulated = false;
|
||||||
try {
|
try {
|
||||||
await loginPage.waitForFunction(
|
await loginPage.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
@@ -80,15 +82,36 @@ export async function loginSatCsf(
|
|||||||
return rfc !== null && rfc.value.length >= 12;
|
return rfc !== null && rfc.value.length >= 12;
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
{ timeout: 30_000 },
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
rfcPopulated = true;
|
||||||
|
} catch {
|
||||||
|
// Fallback: si tenemos el RFC conocido, intentar llenarlo manualmente
|
||||||
|
// (el SAT a veces no auto-popula en tiempo esperado pero acepta el envío igual).
|
||||||
|
if (knownRfc && knownRfc.length >= 12) {
|
||||||
|
try {
|
||||||
|
const rfcInput = loginPage.locator('#rfc').first();
|
||||||
|
await rfcInput.evaluate((el: HTMLElement, val: string) => {
|
||||||
|
(el as HTMLInputElement).disabled = false;
|
||||||
|
(el as HTMLInputElement).value = val;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}, knownRfc);
|
||||||
|
await loginPage.waitForTimeout(500);
|
||||||
|
rfcPopulated = true;
|
||||||
|
} catch {
|
||||||
|
// Manual fill failed, will debug-dump below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rfcPopulated) {
|
||||||
const html = await loginPage.content();
|
const html = await loginPage.content();
|
||||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||||
const debugDir = '/tmp/horux-csf-debug';
|
const debugDir = '/tmp/horux-csf-debug';
|
||||||
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
||||||
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
|
writeFileSync(`${debugDir}/04c-rfc-timeout-html-${Date.now()}.html`, html);
|
||||||
throw err;
|
throw new Error('El SAT no auto-populó el RFC tras subir el certificado y no se pudo llenar manualmente');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password + Enviar
|
// Password + Enviar
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export async function getAllTenants() {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { memberships: { where: { active: true } } as any }
|
select: { memberships: { where: { active: true } } as any }
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,8 @@ export async function getTenantById(id: string) {
|
|||||||
plan: true,
|
plan: true,
|
||||||
databaseName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
verticalProfile: true,
|
||||||
|
codigoPostal: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -57,6 +61,8 @@ export async function createTenant(data: {
|
|||||||
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
||||||
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
||||||
firstPaymentDueAt?: string | null;
|
firstPaymentDueAt?: string | null;
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
}) {
|
}) {
|
||||||
const plan = data.plan || 'trial';
|
const plan = data.plan || 'trial';
|
||||||
|
|
||||||
@@ -70,12 +76,18 @@ export async function createTenant(data: {
|
|||||||
}).catch(err => console.error('[METABASE] Register failed:', err));
|
}).catch(err => console.error('[METABASE] Register failed:', err));
|
||||||
|
|
||||||
// 2. Create tenant record
|
// 2. Create tenant record
|
||||||
|
const isTrial = plan === 'trial';
|
||||||
|
|
||||||
const tenant = await prisma.tenant.create({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.nombre,
|
nombre: data.nombre,
|
||||||
rfc: data.rfc.toUpperCase(),
|
rfc: data.rfc.toUpperCase(),
|
||||||
plan,
|
plan,
|
||||||
databaseName,
|
databaseName,
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
verticalProfile: data.verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal: data.codigoPostal || undefined,
|
||||||
|
trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function ClientesPage() {
|
|||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
firstPaymentDueAt: string;
|
firstPaymentDueAt: string;
|
||||||
|
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
}>({
|
}>({
|
||||||
nombre: '',
|
nombre: '',
|
||||||
rfc: '',
|
rfc: '',
|
||||||
@@ -92,6 +93,7 @@ export default function ClientesPage() {
|
|||||||
adminNombre: '',
|
adminNombre: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
firstPaymentDueAt: '',
|
firstPaymentDueAt: '',
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only global admin can access this page
|
// Only global admin can access this page
|
||||||
@@ -122,7 +124,7 @@ export default function ClientesPage() {
|
|||||||
} else {
|
} else {
|
||||||
await createTenant.mutateAsync(formData);
|
await createTenant.mutateAsync(formData);
|
||||||
}
|
}
|
||||||
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
|
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '', verticalProfile: 'CONTABLE' });
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -142,6 +144,7 @@ export default function ClientesPage() {
|
|||||||
firstPaymentDueAt: sub?.currentPeriodEnd
|
firstPaymentDueAt: sub?.currentPeriodEnd
|
||||||
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
||||||
: '',
|
: '',
|
||||||
|
verticalProfile: tenant.verticalProfile || 'CONTABLE',
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -159,7 +162,7 @@ export default function ClientesPage() {
|
|||||||
const handleCancelForm = () => {
|
const handleCancelForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingTenant(null);
|
setEditingTenant(null);
|
||||||
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '' });
|
setFormData({ nombre: '', rfc: '', plan: 'trial', adminEmail: '', adminNombre: '', amount: 0, firstPaymentDueAt: '', verticalProfile: 'CONTABLE' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewClient = (tenantId: string, tenantName: string) => {
|
const handleViewClient = (tenantId: string, tenantName: string) => {
|
||||||
@@ -477,6 +480,25 @@ export default function ClientesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="verticalProfile">Tipo de Despacho</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.verticalProfile}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CONTABLE">Contable</SelectItem>
|
||||||
|
<SelectItem value="JURIDICO">Jurídico</SelectItem>
|
||||||
|
<SelectItem value="ARQUITECTURA">Arquitectura</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Campos de admin — solo al crear */}
|
{/* Campos de admin — solo al crear */}
|
||||||
{!editingTenant && (
|
{!editingTenant && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTimbres } from '@/lib/hooks/use-facturacion';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||||
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2 } from 'lucide-react';
|
import { Shield, Upload, Check, AlertCircle, Receipt, Palette, Image, Building2, FileText } from 'lucide-react';
|
||||||
|
|
||||||
function CustomizationSection() {
|
function CustomizationSection() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -333,6 +333,29 @@ export default function CsdConfigPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Carta Manifiesto */}
|
||||||
|
{selectedContribuyenteId && orgStatus?.configured && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Carta Manifiesto
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Firma requerida por el SAT/RMF para emitir CFDI. Usa tu e.firma (FIEL).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<iframe
|
||||||
|
src={`https://www.facturapi.io/embedded/manifiesto?organization=${orgStatus.orgId}`}
|
||||||
|
className="w-full rounded-lg border"
|
||||||
|
style={{ height: '640px' }}
|
||||||
|
title="Firma de Carta Manifiesto"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Personalización de factura */}
|
{/* Personalización de factura */}
|
||||||
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection />}
|
{selectedContribuyenteId && orgStatus?.configured && <CustomizationSection />}
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ export const listConstancias = (contribuyenteId?: string) => {
|
|||||||
return apiClient.get<Constancia[]>(`/documentos/constancias${params}`).then(r => r.data);
|
return apiClient.get<Constancia[]>(`/documentos/constancias${params}`).then(r => r.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const consultarConstancia = () =>
|
export const consultarConstancia = (contribuyenteId?: string) => {
|
||||||
apiClient.post<Constancia>('/documentos/constancias/consultar').then(r => r.data);
|
const params = contribuyenteId ? `?contribuyenteId=${encodeURIComponent(contribuyenteId)}` : '';
|
||||||
|
return apiClient.post<Constancia>(`/documentos/constancias/consultar${params}`).then(r => r.data);
|
||||||
|
};
|
||||||
|
|
||||||
export const descargarConstanciaPdf = (id: number) =>
|
export const descargarConstanciaPdf = (id: number) =>
|
||||||
apiClient.get(`/documentos/constancias/${id}/pdf`, { responseType: 'blob' }).then(r => r.data as Blob);
|
apiClient.get(`/documentos/constancias/${id}/pdf`, { responseType: 'blob' }).then(r => r.data as Blob);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface Tenant {
|
|||||||
plan: string;
|
plan: string;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' | null;
|
||||||
|
codigoPostal?: string | null;
|
||||||
_count?: {
|
_count?: {
|
||||||
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
||||||
memberships: number;
|
memberships: number;
|
||||||
@@ -32,6 +34,10 @@ export interface CreateTenantData {
|
|||||||
amount?: number;
|
amount?: number;
|
||||||
/** Solo plan custom: deadline para primer pago (formato ISO YYYY-MM-DD). */
|
/** Solo plan custom: deadline para primer pago (formato ISO YYYY-MM-DD). */
|
||||||
firstPaymentDueAt?: string | null;
|
firstPaymentDueAt?: string | null;
|
||||||
|
/** Tipo de despacho (CONTABLE, JURIDICO, ARQUITECTURA). Default: CONTABLE */
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
/** Código postal del domicilio fiscal (5 dígitos) */
|
||||||
|
codigoPostal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenants(): Promise<Tenant[]> {
|
export async function getTenants(): Promise<Tenant[]> {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ export function useConstancias() {
|
|||||||
export function useConsultarConstancia() {
|
export function useConsultarConstancia() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
|
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
|
||||||
|
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: consultarConstancia,
|
mutationFn: () => consultarConstancia(selectedContribuyenteId || undefined),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['constancias', viewingTenantId] });
|
qc.invalidateQueries({ queryKey: ['constancias', viewingTenantId, selectedContribuyenteId] });
|
||||||
qc.invalidateQueries({ queryKey: ['tenant-info'] });
|
qc.invalidateQueries({ queryKey: ['tenant-info'] });
|
||||||
qc.invalidateQueries({ queryKey: ['regimenes-activos'] });
|
qc.invalidateQueries({ queryKey: ['regimenes-activos'] });
|
||||||
},
|
},
|
||||||
|
|||||||
130
docs/sessions/2026-05-04-fix-clientes-crea-despacho.md
Normal file
130
docs/sessions/2026-05-04-fix-clientes-crea-despacho.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Sesión: Fix — Crear cliente desde `/clientes` registra como despacho
|
||||||
|
|
||||||
|
**Fecha:** 2026-05-04
|
||||||
|
**Bug:** El admin global creaba tenants "legacy" (sin `dbMode`, `verticalProfile`, `trialEndsAt`) desde `/clientes`. Ahora se registran como despachos completos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problema
|
||||||
|
|
||||||
|
### Síntoma
|
||||||
|
Cuando el administrador global creaba un nuevo cliente desde `/clientes`, el tenant se creaba sin los campos de despacho:
|
||||||
|
- `dbMode` = `null` (debería ser `MANAGED`)
|
||||||
|
- `verticalProfile` = `null` (debería ser `CONTABLE`/`JURIDICO`/`ARQUITECTURA`)
|
||||||
|
- `trialEndsAt` = `null` (debería ser +30 días para plan trial)
|
||||||
|
- `codigoPostal` no se seteaba
|
||||||
|
|
||||||
|
Esto causaba que el nuevo cliente no funcionara correctamente como despacho (no aparecía en el selector de despachos, no tenía trial, etc.).
|
||||||
|
|
||||||
|
### Causa raíz
|
||||||
|
El flujo de **registro público** (`/register-despacho` → `signupDespacho()`) sí seteaba estos campos correctamente.
|
||||||
|
Pero el flujo de **creación por admin** (`/clientes` → `createTenant()`) no lo hacía — solo creaba el tenant con `nombre`, `rfc`, `plan`, `databaseName`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cambios realizados
|
||||||
|
|
||||||
|
### 2.1 Backend — `tenants.service.ts`
|
||||||
|
|
||||||
|
**Firma ampliada:**
|
||||||
|
```ts
|
||||||
|
export async function createTenant(data: {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creación del tenant ahora setea despacho fields:**
|
||||||
|
```ts
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
nombre: data.nombre,
|
||||||
|
rfc: data.rfc.toUpperCase(),
|
||||||
|
plan,
|
||||||
|
databaseName,
|
||||||
|
dbMode: 'MANAGED',
|
||||||
|
verticalProfile: data.verticalProfile || 'CONTABLE',
|
||||||
|
codigoPostal: data.codigoPostal || undefined,
|
||||||
|
trialEndsAt: isTrial ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queries `getAllTenants` y `getTenantById` ahora retornan:**
|
||||||
|
- `verticalProfile`
|
||||||
|
- `codigoPostal`
|
||||||
|
|
||||||
|
### 2.2 Backend — `tenants.controller.ts`
|
||||||
|
|
||||||
|
El endpoint `POST /tenants` ahora acepta y pasa:
|
||||||
|
- `verticalProfile` (default: `'CONTABLE'`)
|
||||||
|
- `codigoPostal` (opcional)
|
||||||
|
|
||||||
|
### 2.3 Frontend — `clientes/page.tsx`
|
||||||
|
|
||||||
|
**Nuevo campo en el formulario:** `Tipo de Despacho`
|
||||||
|
- Select con opciones: Contable, Jurídico, Arquitectura
|
||||||
|
- Default: Contable
|
||||||
|
- Se persiste al crear y se carga al editar
|
||||||
|
|
||||||
|
**C.P. deliberadamente omitido:**
|
||||||
|
El campo `codigoPostal` **no** se incluyó en el formulario. Se obtiene automáticamente de la CSF cuando el dueño del despacho sube su FIEL por primera vez (`fiel.service.ts` → `consultarConstancia()` → `sincronizarDatosFiscales()`).
|
||||||
|
|
||||||
|
### 2.4 Frontend — `lib/api/tenants.ts`
|
||||||
|
|
||||||
|
Tipos actualizados:
|
||||||
|
```ts
|
||||||
|
export interface Tenant {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' | null;
|
||||||
|
codigoPostal?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantData {
|
||||||
|
// ... campos existentes ...
|
||||||
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
codigoPostal?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flujo resultante
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin global crea cliente en /clientes
|
||||||
|
↓
|
||||||
|
Tenant creado con:
|
||||||
|
dbMode = 'MANAGED'
|
||||||
|
verticalProfile = (seleccionado por admin, default CONTABLE)
|
||||||
|
trialEndsAt = +30 días (si plan = trial)
|
||||||
|
codigoPostal = undefined (se llenará después)
|
||||||
|
↓
|
||||||
|
Dueño del despacho entra a la plataforma
|
||||||
|
↓
|
||||||
|
Sube FIEL del despacho (primer contribuyente)
|
||||||
|
↓
|
||||||
|
Sistema extrae CSF automáticamente
|
||||||
|
↓
|
||||||
|
Tenant actualizado con codigoPostal, calle, estado, regímenes...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Archivos modificados
|
||||||
|
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---------|--------|
|
||||||
|
| `apps/api/src/services/tenants.service.ts` | `createTenant` setea `dbMode`, `verticalProfile`, `trialEndsAt`, `codigoPostal`; queries retornan nuevos campos |
|
||||||
|
| `apps/api/src/controllers/tenants.controller.ts` | Acepta `verticalProfile` y `codigoPostal` del body |
|
||||||
|
| `apps/web/app/(dashboard)/clientes/page.tsx` | Formulario con selector de `verticalProfile`; C.P. omitido (viene de CSF) |
|
||||||
|
| `apps/web/lib/api/tenants.ts` | Tipos `Tenant` y `CreateTenantData` ampliados |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Notas
|
||||||
|
|
||||||
|
- El campo `codigoPostal` del backend es **opcional** y se dejó en la firma por si en el futuro se quiere pasar manualmente (ej. onboarding asistido).
|
||||||
|
- No se requirió migración de base de datos — `codigoPostal` ya existía como columna `String?` en `Tenant`.
|
||||||
|
- `trialEndsAt` se calcula como `Date.now() + 30 días` para plan `trial`, igual que en `signupDespacho()`.
|
||||||
98
docs/sessions/2026-05-17-facturapi-csf-sync-notifications.md
Normal file
98
docs/sessions/2026-05-17-facturapi-csf-sync-notifications.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Sesión de cambios: 2026-05-17
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Correcciones y mejoras en onboarding de Facturapi, extracción de CSF, sincronización SAT inicial y notificaciones por email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Facturapi onboarding — Datos fiscales + Carta Manifiesto
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
Las organizaciones Facturapi creadas por API quedaban sin datos fiscales (`legal`) y sin flujo para firmar la Carta Manifiesto (requerimiento SAT/RMF).
|
||||||
|
|
||||||
|
### Cambios
|
||||||
|
|
||||||
|
**Backend: `apps/api/src/services/contribuyente-facturapi.service.ts`**
|
||||||
|
- Extraídos helpers reutilizables:
|
||||||
|
- `fetchContribuyenteFiscalData()` — consulta RFC, razón social, régimen, CP, domicilio
|
||||||
|
- `buildLegalPayload()` — construye body para `PUT /legal`
|
||||||
|
- `putOrgLegal()` — ejecuta el PUT
|
||||||
|
- Nuevo `updateOrgLegalOnCreate()` — actualiza automáticamente los datos fiscales tras crear/recuperar una org
|
||||||
|
- `createOrgContribuyente()` ahora llama `updateOrgLegalOnCreate()` en los 3 casos (reused, recreated, fresh create)
|
||||||
|
- Refactorizado `ensureOrgLegalForEmit()` para usar los mismos helpers (DRY)
|
||||||
|
|
||||||
|
**Backend: `apps/api/src/services/sat/sat-csf-login.ts`**
|
||||||
|
- `loginSatCsf()` ahora acepta `knownRfc` opcional
|
||||||
|
- Timeout de espera del RFC auto-populado aumentado de 30s a 60s
|
||||||
|
- Fallback manual: si el SAT no auto-popula el RFC, se llena manualmente con `knownRfc`
|
||||||
|
|
||||||
|
**Backend: `apps/api/src/services/constancia.service.ts`**
|
||||||
|
- Ambas llamadas a `loginSatCsf` ahora pasan `fiel.rfc`
|
||||||
|
|
||||||
|
**Frontend: `apps/web/app/(dashboard)/configuracion/csd/page.tsx`**
|
||||||
|
- Nueva sección "Carta Manifiesto" con iframe embebido de Facturapi
|
||||||
|
- Aparece cuando la org está configurada (`orgStatus?.configured`)
|
||||||
|
- URL: `https://www.facturapi.io/embedded/manifiesto?organization={orgId}`
|
||||||
|
|
||||||
|
**Frontend: `apps/web/lib/api/constancias.ts`**
|
||||||
|
- `consultarConstancia()` ahora acepta `contribuyenteId` opcional
|
||||||
|
|
||||||
|
**Frontend: `apps/web/lib/hooks/use-constancias.ts`**
|
||||||
|
- `useConsultarConstancia` ahora pasa `selectedContribuyenteId` a la API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CSF de Carlos Husberto Torres Romero
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
La FIEL de Carlos Husberto se subió el 16/05 pero la CSF nunca se extrajo. El scraper falló con `page.waitForFunction: Timeout 30000ms exceeded`.
|
||||||
|
|
||||||
|
### Acciones
|
||||||
|
- Actualizados datos fiscales de la org `6a08b242ec01fdfa232ed5cc` en Facturapi:
|
||||||
|
- `tax_system`: 601 → 612
|
||||||
|
- Dirección completa sincronizada desde la CSF
|
||||||
|
- Extraída CSF manualmente desde el servidor (113 CFDIs insertados)
|
||||||
|
- Disparado sync inicial SAT para Carlos Husberto (job en progreso)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sync inicial SAT por contribuyente
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
`needsInitialSync()` solo verificaba a nivel tenant. Si un tenant ya había tenido un sync inicial (legacy), los nuevos contribuyentes nunca recibían su extracción histórica.
|
||||||
|
|
||||||
|
### Cambios
|
||||||
|
|
||||||
|
**`apps/api/src/jobs/sat-sync.job.ts`**
|
||||||
|
- `needsInitialSync()` ahora acepta `contribuyenteId` opcional
|
||||||
|
- Si se pasa: busca jobs `initial` completados para ese contribuyente
|
||||||
|
- Si no: mantiene comportamiento legacy a nivel tenant
|
||||||
|
- `syncTenant()` determina `initial` vs `daily` por cada contribuyente dentro del loop
|
||||||
|
- `incrementalSyncTenant()` verifica que cada contribuyente tenga su `initial` antes de hacer incremental
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Notificaciones de documentos para admins
|
||||||
|
|
||||||
|
### Problema
|
||||||
|
Cuando un global_admin subía una declaración/documento para otro tenant, la notificación buscaba los owners del **tenant del admin** en lugar del tenant destino.
|
||||||
|
|
||||||
|
### Cambios
|
||||||
|
|
||||||
|
**`apps/api/src/controllers/documentos.controller.ts`**
|
||||||
|
- `notifyDocumentoSubido` ahora usa `req.viewingTenantId ?? req.user!.tenantId`
|
||||||
|
- Corregido en ambos endpoints: crear declaración y crear documento extra
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos modificados
|
||||||
|
|
||||||
|
- `apps/api/src/controllers/documentos.controller.ts`
|
||||||
|
- `apps/api/src/jobs/sat-sync.job.ts`
|
||||||
|
- `apps/api/src/services/constancia.service.ts`
|
||||||
|
- `apps/api/src/services/contribuyente-facturapi.service.ts`
|
||||||
|
- `apps/api/src/services/sat/sat-csf-login.ts`
|
||||||
|
- `apps/web/app/(dashboard)/configuracion/csd/page.tsx`
|
||||||
|
- `apps/web/lib/api/constancias.ts`
|
||||||
|
- `apps/web/lib/hooks/use-constancias.ts`
|
||||||
Reference in New Issue
Block a user