Initial commit - Horux Despachos NL
This commit is contained in:
493
apps/api/src/services/contribuyente-facturapi.service.ts
Normal file
493
apps/api/src/services/contribuyente-facturapi.service.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import Facturapi from 'facturapi';
|
||||
import type { Pool } from 'pg';
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import { env } from '../config/env.js';
|
||||
import { encryptString, decryptToString } from './sat/sat-crypto.service.js';
|
||||
|
||||
function getUserClient(): Facturapi {
|
||||
if (!env.FACTURAPI_USER_KEY) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||
return new Facturapi(env.FACTURAPI_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera una Live Secret Key para una organización Facturapi via PUT idempotente.
|
||||
* Si la org ya tiene live key, devuelve la existente; si no, crea una nueva.
|
||||
* Endpoint oficial Facturapi: PUT /v2/organizations/{id}/apikeys/live
|
||||
*/
|
||||
async function generateLiveKey(orgId: string): Promise<string> {
|
||||
const userKey = env.FACTURAPI_USER_KEY!;
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`);
|
||||
}
|
||||
const key = (await res.text()).replace(/"/g, '').trim();
|
||||
if (!key.startsWith('sk_live_')) {
|
||||
throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cifra y persiste la Live Secret Key de una organización.
|
||||
* AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY.
|
||||
*/
|
||||
async function persistEncryptedKey(pool: Pool, orgId: string, plaintextKey: string): Promise<void> {
|
||||
const { encrypted, iv, tag } = encryptString(plaintextKey);
|
||||
await pool.query(
|
||||
`UPDATE facturapi_orgs SET api_key_enc = $1, api_key_iv = $2, api_key_tag = $3 WHERE facturapi_org_id = $4`,
|
||||
[encrypted, iv, tag, orgId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la Live Secret Key cacheada (descifra de BD) o la genera vía PUT
|
||||
* y la persiste si no existe (caso de orgs legacy creadas antes del refactor live).
|
||||
*/
|
||||
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
|
||||
const { rows } = await pool.query<{ api_key_enc: Buffer | null; api_key_iv: Buffer | null; api_key_tag: Buffer | null }>(
|
||||
`SELECT api_key_enc, api_key_iv, api_key_tag FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length === 0) throw new Error(`Organización ${orgId} no encontrada en BD tenant`);
|
||||
|
||||
const row = rows[0];
|
||||
if (row.api_key_enc && row.api_key_iv && row.api_key_tag) {
|
||||
return decryptToString(row.api_key_enc, row.api_key_iv, row.api_key_tag);
|
||||
}
|
||||
|
||||
// Org legacy sin live key cacheada — generar y guardar (idempotente).
|
||||
const apiKey = await generateLiveKey(orgId);
|
||||
await persistEncryptedKey(pool, orgId, apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function createOrgContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
nombre: string
|
||||
): Promise<{ orgId: string; reused?: boolean; recreated?: boolean }> {
|
||||
const { rows: existing } = await pool.query(
|
||||
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1',
|
||||
[contribuyenteId]
|
||||
);
|
||||
const client = getUserClient();
|
||||
|
||||
// Caso 1: hay fila local → verificar si la org sigue viva en Facturapi.
|
||||
// Si existe en ambos lados, idempotente (devolver la existente).
|
||||
// Si existe solo local pero Facturapi no la tiene (eliminada allá, API key
|
||||
// cambió, etc.), recrear y actualizar el FK local — desbloquea el flujo
|
||||
// de CSD que si no se quedaba trabado.
|
||||
if (existing.length > 0) {
|
||||
const existingId = existing[0].facturapi_org_id;
|
||||
try {
|
||||
await client.organizations.retrieve(existingId);
|
||||
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
||||
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
||||
await ensureLiveKeyCached(pool, existingId);
|
||||
return { orgId: existingId, reused: true };
|
||||
} catch {
|
||||
const org = await client.organizations.create({ name: nombre });
|
||||
await pool.query(
|
||||
'UPDATE facturapi_orgs SET facturapi_org_id = $2, csd_uploaded = false, active = true, api_key_enc = NULL, api_key_iv = NULL, api_key_tag = NULL WHERE contribuyente_id = $1',
|
||||
[contribuyenteId, org.id]
|
||||
);
|
||||
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
||||
await ensureLiveKeyCached(pool, org.id);
|
||||
return { orgId: org.id, recreated: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 2: no hay fila local → crear fresh.
|
||||
const org = await client.organizations.create({ name: nombre });
|
||||
await pool.query(
|
||||
'INSERT INTO facturapi_orgs (contribuyente_id, facturapi_org_id) VALUES ($1, $2)',
|
||||
[contribuyenteId, org.id]
|
||||
);
|
||||
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
||||
await ensureLiveKeyCached(pool, org.id);
|
||||
return { orgId: org.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantiza que la org tiene su Live Secret Key cifrada en BD. Si ya existe,
|
||||
* no-op. Si no, hace PUT live y la persiste. Idempotente — el endpoint
|
||||
* Facturapi PUT /apikeys/live es idempotente, devuelve la misma key si ya
|
||||
* existe en su lado.
|
||||
*/
|
||||
async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM facturapi_orgs WHERE facturapi_org_id = $1 AND api_key_enc IS NOT NULL LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length > 0) return;
|
||||
const apiKey = await generateLiveKey(orgId);
|
||||
await persistEncryptedKey(pool, orgId, apiKey);
|
||||
}
|
||||
|
||||
export async function getOrgStatusContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string
|
||||
): Promise<{ configured: boolean; orgId?: string; legalName?: string; hasCsd?: boolean }> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT facturapi_org_id, csd_uploaded FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) return { configured: false };
|
||||
|
||||
try {
|
||||
const client = getUserClient();
|
||||
const org = await client.organizations.retrieve(rows[0].facturapi_org_id);
|
||||
return {
|
||||
configured: true,
|
||||
orgId: org.id,
|
||||
legalName: org.legal?.name || undefined,
|
||||
hasCsd: !!org.certificate?.has_certificate,
|
||||
};
|
||||
} catch {
|
||||
return { configured: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadCsdContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
cerFile: string,
|
||||
keyFile: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string; rfc: string }>(
|
||||
`SELECT fo.facturapi_org_id, c.rfc
|
||||
FROM facturapi_orgs fo
|
||||
JOIN contribuyentes c ON c.entidad_id = fo.contribuyente_id
|
||||
WHERE fo.contribuyente_id = $1 AND fo.active = true`,
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Primero debe crearse la organización Facturapi del contribuyente');
|
||||
|
||||
const { facturapi_org_id, rfc: contribuyenteRfc } = rows[0];
|
||||
|
||||
// Validación preventiva: que el certificado sea CSD (no FIEL), que el RFC
|
||||
// coincida con el contribuyente y que no esté vencido. Facturapi también
|
||||
// valida, pero su mensaje de error es poco específico ("Certificado no
|
||||
// válido") — el nuestro dice exactamente qué pasa.
|
||||
const cerData = Buffer.from(cerFile, 'base64');
|
||||
const keyData = Buffer.from(keyFile, 'base64');
|
||||
|
||||
let credential: Credential;
|
||||
try {
|
||||
credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password);
|
||||
} catch {
|
||||
return { success: false, message: 'Los archivos .cer/.key no son válidos o la contraseña es incorrecta' };
|
||||
}
|
||||
|
||||
// Debe ser CSD (sello digital para facturar), no FIEL (e.firma para trámites).
|
||||
if (credential.isFiel()) {
|
||||
return { success: false, message: 'El certificado es una FIEL (e.firma), no un CSD. Sube el Certificado de Sello Digital.' };
|
||||
}
|
||||
|
||||
const certRfc = credential.certificate().rfc().toUpperCase();
|
||||
if (certRfc !== contribuyenteRfc.toUpperCase()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `El RFC del CSD (${certRfc}) no coincide con el del contribuyente (${contribuyenteRfc}). Verifica que estés subiendo los archivos correctos.`,
|
||||
};
|
||||
}
|
||||
|
||||
const validUntil = new Date(String(credential.certificate().validToDateTime()));
|
||||
if (new Date() > validUntil) {
|
||||
return { success: false, message: `El CSD está vencido desde ${validUntil.toLocaleDateString('es-MX')}. Solicita al SAT uno nuevo.` };
|
||||
}
|
||||
|
||||
const client = getUserClient();
|
||||
try {
|
||||
await client.organizations.uploadCertificate(
|
||||
facturapi_org_id,
|
||||
cerData,
|
||||
keyData,
|
||||
password,
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE facturapi_orgs SET csd_uploaded = true WHERE contribuyente_id = $1',
|
||||
[contribuyenteId]
|
||||
);
|
||||
return { success: true, message: 'CSD subido correctamente' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Error al subir CSD a Facturapi' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgClientContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string
|
||||
): Promise<Facturapi> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
|
||||
|
||||
const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id);
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
export async function cancelInvoiceContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
motive: '01' | '02' | '03' | '04' = '02',
|
||||
substitution?: string,
|
||||
): Promise<any> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const cancelData: any = { motive };
|
||||
if (motive === '01' && substitution) cancelData.substitution = substitution;
|
||||
return client.invoices.cancel(facturapiId, cancelData);
|
||||
}
|
||||
|
||||
function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (Buffer.isBuffer(stream)) return resolve(stream);
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadPdfContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
): Promise<Buffer> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const stream = await client.invoices.downloadPdf(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function downloadXmlContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
): Promise<Buffer> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const stream = await client.invoices.downloadXml(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function sendInvoiceByEmailContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
await client.invoices.sendByEmail(facturapiId, { email });
|
||||
}
|
||||
|
||||
export async function createInvoiceContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
data: any
|
||||
): Promise<any> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
|
||||
// Create/update customer in Facturapi
|
||||
const isForiegn = !!data.customer?.country && data.customer.country !== 'MEX';
|
||||
const customerData: any = {
|
||||
legal_name: data.customer?.legalName,
|
||||
tax_id: data.customer?.taxId,
|
||||
email: data.customer?.email,
|
||||
address: { zip: data.customer?.zip, ...(isForiegn ? { country: data.customer.country } : {}) },
|
||||
};
|
||||
if (!isForiegn && data.customer?.taxSystem) customerData.tax_system = data.customer.taxSystem;
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
const existing = await client.customers.list({ search: data.customer?.taxId });
|
||||
const match = existing.data?.find((c: any) => c.tax_id === data.customer?.taxId);
|
||||
if (match) {
|
||||
await client.customers.update(match.id, customerData);
|
||||
customerId = match.id;
|
||||
} else {
|
||||
const created = await client.customers.create(customerData);
|
||||
customerId = created.id;
|
||||
}
|
||||
} catch {
|
||||
const created = await client.customers.create(customerData);
|
||||
customerId = created.id;
|
||||
}
|
||||
|
||||
// Build invoice payload (mirrors createInvoice logic in facturapi.service.ts)
|
||||
const tipo = data.type || 'I';
|
||||
const invoicePayload: any = { customer: customerId };
|
||||
|
||||
if (tipo !== 'I') invoicePayload.type = tipo;
|
||||
|
||||
if (data.items?.length) {
|
||||
invoicePayload.items = data.items.map((item: any) => ({
|
||||
quantity: item.quantity,
|
||||
product: {
|
||||
description: item.description,
|
||||
product_key: item.productKey,
|
||||
unit_key: item.unitKey || 'E48',
|
||||
unit_name: item.unitName || 'Servicio',
|
||||
price: item.price,
|
||||
tax_included: item.taxIncluded ?? true,
|
||||
taxes: item.taxes?.map((t: any) => ({
|
||||
type: t.type,
|
||||
rate: t.rate,
|
||||
factor: t.factor || 'Tasa',
|
||||
...(t.withholding ? { withholding: true } : {}),
|
||||
})) || [{ type: 'IVA', rate: 0.16 }],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (tipo === 'I' || tipo === 'E') {
|
||||
invoicePayload.use = data.use || 'G01';
|
||||
invoicePayload.payment_form = data.paymentForm || '99';
|
||||
invoicePayload.payment_method = data.paymentMethod || 'PUE';
|
||||
invoicePayload.currency = data.currency || 'MXN';
|
||||
if (data.exchangeRate && data.currency !== 'MXN') invoicePayload.exchange = data.exchangeRate;
|
||||
if (data.conditions) invoicePayload.conditions = data.conditions;
|
||||
}
|
||||
|
||||
if (data.series) invoicePayload.series = data.series;
|
||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||
|
||||
if (data.relatedDocuments?.length) {
|
||||
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
||||
// el formato nuevo {relationship, uuids[]} como el legacy {relationship,
|
||||
// uuid} para compat durante transición de callers frontend.
|
||||
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
||||
relationship: r.relationship,
|
||||
documents: Array.isArray(r.uuids) ? r.uuids : (r.uuid ? [r.uuid] : []),
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.complements?.length) invoicePayload.complements = data.complements;
|
||||
if (data.global) invoicePayload.global = data.global;
|
||||
|
||||
// Régimen fiscal del emisor: Facturapi NO acepta override per-invoice via
|
||||
// campo `issuer` (rechaza con "issuer is not allowed"). La única forma es
|
||||
// actualizar el `legal.tax_system` de la organización antes del emit.
|
||||
// Para contribuyentes con múltiples regímenes, esto significa un sync en
|
||||
// cada emit cuando el seleccionado difiere del actual en la org.
|
||||
if (data.issuerTaxSystem) {
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await ensureOrgLegalForEmit(pool, contribuyenteId, rows[0].facturapi_org_id, data.issuerTaxSystem);
|
||||
}
|
||||
}
|
||||
|
||||
return client.invoices.create(invoicePayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza los datos fiscales de la organización Facturapi con la
|
||||
* información del contribuyente, usando el régimen seleccionado. Se llama
|
||||
* antes de cada emit cuando el user elige un régimen en el form, porque
|
||||
* Facturapi toma el TaxSystem del CFDI del `legal.tax_system` de la org
|
||||
* (no acepta override per-invoice). No-op si el `legal` ya coincide.
|
||||
*/
|
||||
async function ensureOrgLegalForEmit(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
orgId: string,
|
||||
chosenTaxSystem: string,
|
||||
): Promise<void> {
|
||||
const userKey = env.FACTURAPI_USER_KEY;
|
||||
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||
|
||||
// Datos fiscales del contribuyente (razón social + domicilio)
|
||||
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
|
||||
const allowed = (contrib.regimen_fiscal || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
if (allowed.length > 0 && !allowed.includes(chosenTaxSystem)) {
|
||||
throw new Error(
|
||||
`El régimen ${chosenTaxSystem} no está registrado para este contribuyente ` +
|
||||
`(registrados: ${allowed.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Leer el legal actual de la org en Facturapi
|
||||
const getRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
});
|
||||
if (!getRes.ok) {
|
||||
throw new Error(`No se pudo leer organización Facturapi (${getRes.status})`);
|
||||
}
|
||||
const org = (await getRes.json()) as any;
|
||||
const currentLegal = org.legal || {};
|
||||
|
||||
// Si el tax_system ya coincide y la razón social está seteada, no tocar
|
||||
// (evita updates innecesarios con latencia extra).
|
||||
if (
|
||||
currentLegal.tax_system === chosenTaxSystem &&
|
||||
currentLegal.legal_name &&
|
||||
currentLegal.legal_name === contrib.razon_social
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domicilio = (contrib.domicilio || {}) as any;
|
||||
const legalPayload = {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user