342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
class PixQRGenerator {
|
|
constructor() {
|
|
this.initializePixInterface();
|
|
}
|
|
|
|
initializePixInterface() {
|
|
// Listeners for realtime preview
|
|
const fieldsToWatch = [
|
|
'pix-key',
|
|
'pix-key-type',
|
|
'pix-name',
|
|
'pix-city',
|
|
'pix-amount',
|
|
'pix-description',
|
|
'pix-txid'
|
|
];
|
|
|
|
fieldsToWatch.forEach(fieldId => {
|
|
const element = document.getElementById(fieldId);
|
|
if (element) {
|
|
element.addEventListener('input', () => this.updatePreview());
|
|
element.addEventListener('change', () => this.updatePreview());
|
|
}
|
|
});
|
|
|
|
// Add currency mask to amount field
|
|
const amountInput = document.getElementById('pix-amount');
|
|
if (amountInput) {
|
|
amountInput.addEventListener('input', (e) => {
|
|
let value = e.target.value.replace(/\D/g, '');
|
|
if (value) {
|
|
value = (parseInt(value) / 100).toFixed(2) + '';
|
|
value = value.replace('.', ',');
|
|
value = value.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1.');
|
|
e.target.value = value;
|
|
}
|
|
this.updatePreview();
|
|
});
|
|
}
|
|
}
|
|
|
|
updatePreview() {
|
|
const data = this.collectPixData();
|
|
const previewElement = document.getElementById('pix-preview-text');
|
|
|
|
if (previewElement) {
|
|
// Validação de "Preguiça" (Laziness Validation)
|
|
// Checks if user selected one type but entered another
|
|
const validationWarning = this.validateKeyType(data.keyType, data.key);
|
|
|
|
if (validationWarning) {
|
|
previewElement.innerHTML = `<span class="text-danger"><i class="fas fa-exclamation-triangle"></i> ${validationWarning}</span>`;
|
|
return;
|
|
}
|
|
|
|
if (!data.key || !data.name || !data.city) {
|
|
previewElement.textContent = "Preencha os campos obrigatórios (Chave, Nome, Cidade) para ver o preview.";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = this.generatePixPayload();
|
|
previewElement.textContent = payload;
|
|
} catch (e) {
|
|
previewElement.textContent = "Erro ao gerar payload: " + e.message;
|
|
}
|
|
}
|
|
}
|
|
|
|
validatePixData() {
|
|
const data = this.collectPixData();
|
|
const errors = [];
|
|
|
|
// Required fields
|
|
if (!data.key.trim()) errors.push('Chave PIX é obrigatória');
|
|
if (!data.name.trim()) errors.push('Nome do beneficiário é obrigatório');
|
|
if (!data.city.trim()) errors.push('Cidade do beneficiário é obrigatória');
|
|
|
|
// Length limits
|
|
if (data.name.length > 25) errors.push('Nome deve ter no máximo 25 caracteres');
|
|
if (data.city.length > 15) errors.push('Cidade deve ter no máximo 15 caracteres (Regra do Banco Central)');
|
|
|
|
// Amount validation
|
|
if (data.amount) {
|
|
const amount = parseFloat(data.amount.replace(/\./g, '').replace(',', '.'));
|
|
if (isNaN(amount) || amount <= 0) {
|
|
errors.push('Valor deve ser um número maior que zero');
|
|
}
|
|
}
|
|
|
|
// Smart Key Validation
|
|
const keyWarning = this.validateKeyType(data.keyType, data.key);
|
|
if (keyWarning) {
|
|
errors.push(keyWarning);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
validateKeyType(type, key) {
|
|
if (!key) return null;
|
|
const cleanKey = key.replace(/\D/g, ''); // Numbers only
|
|
|
|
// Case 1: Selected CPF/CNPJ
|
|
if (type === 'cpf') {
|
|
if (cleanKey.length === 11) {
|
|
if (!this.isValidCPF(cleanKey)) {
|
|
// Check if looks like phone (DDD + 9...)
|
|
if (cleanKey[2] === '9') {
|
|
return "Selecionado CPF, mas parece um Celular. Altere o tipo para 'Celular' ou corrija.";
|
|
}
|
|
return "CPF Inválido. Verifique os dígitos.";
|
|
}
|
|
} else if (cleanKey.length === 14) {
|
|
if (!this.isValidCNPJ(cleanKey)) {
|
|
return "CNPJ Inválido. Verifique os dígitos.";
|
|
}
|
|
} else {
|
|
if (cleanKey.length > 0) return `CPF/CNPJ deve ter 11 ou 14 números. (Atual: ${cleanKey.length})`;
|
|
}
|
|
}
|
|
|
|
// Case 2: Selected Phone
|
|
if (type === 'phone') {
|
|
if (cleanKey.length > 0) {
|
|
if (cleanKey.length !== 10 && cleanKey.length !== 11) {
|
|
return `Telefone deve ter 10 ou 11 números (DDD + Número). (Atual: ${cleanKey.length})`;
|
|
}
|
|
|
|
// Smart check: Typed a CPF in Phone field?
|
|
if (cleanKey.length === 11 && this.isValidCPF(cleanKey)) {
|
|
// Check if it's NOT a typical mobile number structure
|
|
// Mobile: DDD (11-99) + 9 + 8 digits.
|
|
const ddd = parseInt(cleanKey.substring(0, 2));
|
|
const ninthDigit = cleanKey[2];
|
|
|
|
// If DDD is invalid or 9th digit is not 9, but it IS a valid CPF -> Warning
|
|
if (ddd < 11 || ddd > 99 || ninthDigit !== '9') {
|
|
return "Isso parece ser um CPF válido, mas o tipo selecionado é 'Celular'.";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Case 3: Email
|
|
if (type === 'email') {
|
|
if (key.length > 0 && !key.includes('@')) {
|
|
return "Email inválido. Deve conter '@'.";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
isValidCPF(cpf) {
|
|
if (typeof cpf !== "string") return false;
|
|
cpf = cpf.replace(/[\s.-]*/igm, '');
|
|
if (cpf.length !== 11 || !Array.from(cpf).filter(e => e !== cpf[0]).length) {
|
|
return false;
|
|
}
|
|
var sum = 0;
|
|
var remainder;
|
|
for (var i = 1; i <= 9; i++)
|
|
sum = sum + parseInt(cpf.substring(i-1, i)) * (11 - i);
|
|
remainder = (sum * 10) % 11;
|
|
if ((remainder == 10) || (remainder == 11)) remainder = 0;
|
|
if (remainder != parseInt(cpf.substring(9, 10)) ) return false;
|
|
sum = 0;
|
|
for (i = 1; i <= 10; i++)
|
|
sum = sum + parseInt(cpf.substring(i-1, i)) * (12 - i);
|
|
remainder = (sum * 10) % 11;
|
|
if ((remainder == 10) || (remainder == 11)) remainder = 0;
|
|
if (remainder != parseInt(cpf.substring(10, 11)) ) return false;
|
|
return true;
|
|
}
|
|
|
|
isValidCNPJ(cnpj) {
|
|
if (!cnpj) return false;
|
|
cnpj = cnpj.replace(/[^\d]+/g,'');
|
|
if (cnpj.length != 14) return false;
|
|
// Elimina CNPJs invalidos conhecidos
|
|
if (cnpj == "00000000000000" ||
|
|
cnpj == "11111111111111" ||
|
|
cnpj == "22222222222222" ||
|
|
cnpj == "33333333333333" ||
|
|
cnpj == "44444444444444" ||
|
|
cnpj == "55555555555555" ||
|
|
cnpj == "66666666666666" ||
|
|
cnpj == "77777777777777" ||
|
|
cnpj == "88888888888888" ||
|
|
cnpj == "99999999999999")
|
|
return false;
|
|
|
|
// Valida DVs
|
|
let tamanho = cnpj.length - 2
|
|
let numeros = cnpj.substring(0,tamanho);
|
|
let digitos = cnpj.substring(tamanho);
|
|
let soma = 0;
|
|
let pos = tamanho - 7;
|
|
for (let i = tamanho; i >= 1; i--) {
|
|
soma += numeros.charAt(tamanho - i) * pos--;
|
|
if (pos < 2) pos = 9;
|
|
}
|
|
let resultado = soma % 11 < 2 ? 0 : 11 - soma % 11;
|
|
if (resultado != digitos.charAt(0)) return false;
|
|
|
|
tamanho = tamanho + 1;
|
|
numeros = cnpj.substring(0,tamanho);
|
|
soma = 0;
|
|
pos = tamanho - 7;
|
|
for (let i = tamanho; i >= 1; i--) {
|
|
soma += numeros.charAt(tamanho - i) * pos--;
|
|
if (pos < 2) pos = 9;
|
|
}
|
|
resultado = soma % 11 < 2 ? 0 : 11 - soma % 11;
|
|
if (resultado != digitos.charAt(1)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
collectPixData() {
|
|
return {
|
|
keyType: document.getElementById('pix-key-type')?.value || 'cpf',
|
|
key: document.getElementById('pix-key')?.value || '',
|
|
name: document.getElementById('pix-name')?.value || '',
|
|
city: document.getElementById('pix-city')?.value || '',
|
|
amount: document.getElementById('pix-amount')?.value || '',
|
|
description: document.getElementById('pix-description')?.value || '',
|
|
txid: document.getElementById('pix-txid')?.value || ''
|
|
};
|
|
}
|
|
|
|
generateCRC16(payload) {
|
|
let crc = 0xFFFF;
|
|
const polynomial = 0x1021;
|
|
|
|
for (let i = 0; i < payload.length; i++) {
|
|
crc ^= payload.charCodeAt(i) << 8;
|
|
for (let j = 0; j < 8; j++) {
|
|
if ((crc & 0x8000) !== 0) {
|
|
crc = ((crc << 1) ^ polynomial) & 0xFFFF;
|
|
} else {
|
|
crc = (crc << 1) & 0xFFFF;
|
|
}
|
|
}
|
|
}
|
|
|
|
return crc.toString(16).toUpperCase().padStart(4, '0');
|
|
}
|
|
|
|
formatField(id, value) {
|
|
const valStr = value.toString();
|
|
const len = valStr.length.toString().padStart(2, '0');
|
|
return `${id}${len}${valStr}`;
|
|
}
|
|
|
|
formatKey(type, key) {
|
|
if (!key) return '';
|
|
const cleanKey = key.trim();
|
|
|
|
switch(type) {
|
|
case 'phone':
|
|
// Remove non-digits
|
|
let nums = cleanKey.replace(/\D/g, '');
|
|
// If 10 or 11 digits (DDD+Number), add +55
|
|
if ((nums.length === 10 || nums.length === 11) && !nums.startsWith('55')) {
|
|
return `+55${nums}`;
|
|
}
|
|
// If user typed 55..., ensure +
|
|
if (nums.startsWith('55') && !cleanKey.startsWith('+')) {
|
|
return `+${nums}`;
|
|
}
|
|
// If user typed +..., keep it
|
|
if (cleanKey.startsWith('+')) {
|
|
return cleanKey; // Assume correct
|
|
}
|
|
return `+${nums}`; // Fallback
|
|
|
|
case 'cpf':
|
|
case 'cnpj':
|
|
// Numbers only
|
|
return cleanKey.replace(/\D/g, '');
|
|
|
|
case 'email':
|
|
return cleanKey.toLowerCase();
|
|
|
|
case 'random':
|
|
default:
|
|
return cleanKey;
|
|
}
|
|
}
|
|
|
|
generatePixPayload() {
|
|
const data = this.collectPixData();
|
|
|
|
// Format key based on type
|
|
const key = this.formatKey(data.keyType, data.key);
|
|
|
|
const name = this.removeAccents(data.name.trim()).substring(0, 25);
|
|
const city = this.removeAccents(data.city.trim()).substring(0, 15); // Standard limit 15 chars
|
|
const amount = data.amount ? parseFloat(data.amount.replace(/\./g, '').replace(',', '.')).toFixed(2) : null;
|
|
const description = this.removeAccents(data.description.trim()) || '';
|
|
const txid = this.removeAccents(data.txid.trim()) || '***';
|
|
|
|
let payload = this.formatField('00', '01');
|
|
|
|
const gui = this.formatField('00', 'br.gov.bcb.pix');
|
|
const keyField = this.formatField('01', key);
|
|
|
|
let merchantAccountInfo = gui + keyField;
|
|
if (description) {
|
|
merchantAccountInfo += this.formatField('02', description);
|
|
}
|
|
payload += this.formatField('26', merchantAccountInfo);
|
|
|
|
payload += this.formatField('52', '0000'); // Merchant Category Code
|
|
payload += this.formatField('53', '986'); // Transaction Currency (BRL)
|
|
|
|
if (amount) {
|
|
payload += this.formatField('54', amount); // Transaction Amount
|
|
}
|
|
|
|
payload += this.formatField('58', 'BR'); // Country Code
|
|
payload += this.formatField('59', name); // Merchant Name
|
|
payload += this.formatField('60', city); // Merchant City
|
|
|
|
// Field 62: Additional Data Field Template
|
|
// Subfields: 05 (Reference Label / TxID)
|
|
const txidField = this.formatField('05', txid);
|
|
payload += this.formatField('62', txidField);
|
|
|
|
payload += '6304'; // CRC16 ID + Length
|
|
|
|
const crc = this.generateCRC16(payload);
|
|
|
|
return payload + crc;
|
|
}
|
|
|
|
removeAccents(str) {
|
|
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
}
|
|
} |