QrRapido/wwwroot/js/pix-generator.js
2026-01-25 00:05:26 -03:00

210 lines
7.6 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) {
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;
}
}
}
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 || ''
};
}
validatePixData() {
const data = this.collectPixData();
const errors = [];
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');
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)');
if (data.amount) {
// Remove thousands separators (.) and replace decimal comma (,) with dot (.)
const amount = parseFloat(data.amount.replace(/\./g, '').replace(',', '.'));
if (isNaN(amount) || amount <= 0) {
errors.push('Valor deve ser um número maior que zero');
}
}
return errors;
}
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 it starts with 55 and is long enough, keep it
// 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);
// Field 26: Merchant Account Information
// Subfields: 00 (GUI), 01 (Key), 02 (Description - optional/standard varies)
// Standard EMVCo for PIX puts Description in field 02 of ID 26?
// Actually, BR Code standard puts "InfoAdicional" in Field 26, ID 02.
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, "");
}
}