Skip to content
paymenter paymenter

paymenter

Tickets do Paymenter em tempo real (ponte push nos dois sentidos) + blocks de faturamento.

About this extension

@unifydesk/extension-paymenter

Tickets do Paymenter dentro do UnifyDesk, em tempo real e nos dois sentidos — sem polling e sem entregar uma admin API key para a conversa.

PORT=3020 bun extensions/paymenter/src/server.ts

Como funciona

O Paymenter não emite webhook de ticket. A alternativa era o conector varrer /api/v1/admin/tickets de tempos em tempos: resposta atrasada, requisição constante mesmo sem novidade, e uma chave de admin (que abre a instalação inteira) guardada por um terceiro processo.

Então a ponte é uma extension do próprio Paymenter — php/Unifydesk.php, tipo Other. Ela escuta os eventos de domínio (Ticket\Created, TicketMessage\Created, Ticket\Updated) e faz POST no endereço público desta conexão; a resposta do atendente volta por POST /extensions/unifydesk/reply, que a mesma extension serve. Os dois sentidos são assinados com HMAC-SHA256 sobre os bytes crus de uma única chave compartilhada.

Paymenter  --(evento de domínio)-->  POST <painel>/api/extensions/paymenter/<conn>/hook
Paymenter  <--(resposta do agente)-- POST <paymenter>/extensions/unifydesk/reply

Instalar

Tudo pela tela da conexão (aba de configuração da conexão no painel):

  1. URL do Paymenter — salvar.

  2. Baixar unifydesk.zip — sai já com o endereço desta conexão e a chave compartilhada gravados no próprio código:

    unifydesk.zip
    └── Unifydesk/
        └── Unifydesk.php
    

    O pacote vem por RPC (host→extension, autenticado) e o navegador monta o arquivo a partir do base64. Não passa pelo relay público: ele lê a resposta da extension como TEXTO, então os bytes do zip chegariam corrompidos — e a chave compartilhada nunca precisa viajar numa URL sem credencial.

  3. Descompactar dentro de extensions/Others/ na instalação do Paymenter e instalar Unifydesk em Extensions → Other. Não há nada a preencher: endereço e chave não são campos do formulário, são constantes gravadas no download. Pedi-los de novo seria pedir ao admin que copiasse à mão dois valores que o sistema já conhece — e um caractere errado na chave produz uma ponte que só se manifesta como 401.

    O que o formulário do Paymenter mostra é o ESTADO (endereço gravado) mais o que é decisão local: o usuário autor das respostas e se manda mudança de status.

A pasta, o arquivo e a classe têm o mesmo nome porque é assim que o Paymenter descobre a extension (PaymenterExtensionsOthersUnifydeskUnifydesk).

O botão Verificar instalação chama GET /extensions/unifydesk/ping lá. Sem a ponte instalada a conexão fica em needs_auth (bridge_not_installed) — é passo pendente de uma pessoa, não falha de rede, então o supervisor não fica retentando nem pinta vermelho com backoff eterno.

Gerar nova chave rotaciona o segredo: o arquivo já instalado passa a ser recusado (401 → shared_secret_mismatch) até baixarem de novo.

O que atravessa

Paymenter UnifyDesk
ticket aberto ticket com referência ticket:<id>, propriedades paymenter_*
mensagem do cliente mensagem de ENTRADA (dispara automação/bot, conta não-lida)
mensagem do staff (feita no admin do Paymenter) mensagem de SAÍDA já enviada
mudança de status propriedade paymenter_status do ticket
resposta do atendente no UnifyDesk TicketMessage no ticket, status → replied

Anexos viajam inline em base64 (até 8 MB): o arquivo no Paymenter fica atrás da sessão do cliente, então uma URL seria um anexo que o atendente não abre.

Eco: a extension marca UnifyDesk::$suppress enquanto grava a resposta que veio do painel — sem isso cada resposta voltaria para quem a mandou.

Config

Nível extension (global ao processo) e nível conexão (sobrescreve):

  • apiUrl — endereço do Paymenter. É o único campo que se digita.
  • webhookSecret — gerado automaticamente na conexão, persistido via saveConfig. Não é para digitar.
  • verifyInstall — checar a ponte no start (desligue se o Paymenter não for alcançável a partir do processo).

Não há API key. A admin API key do Paymenter abre a instalação inteira (usuários, faturas, servidores) e ficaria guardada neste processo só para um block responder "qual é o cliente desse e-mail". Toda leitura passa pela mesma ponte assinada, em POST /extensions/unifydesk/query, cuja lista de operações é fechada e somente-leitura — o teto do canal é o que os blocks realmente pedem.

Blocks de automação

Todos consultam pela ponte (op → operação declarada no PHP), com teto de 25 linhas por resposta: um block não pagina.

  • action.paymenter_customer — cliente por e-mail ou id (op: customer).
  • action.paymenter_services — serviços do cliente (op: services).
  • action.paymenter_invoices — faturas do cliente, com status opcional (op: invoices).

Avisos de faturamento (a segunda metade)

Espelho do que a extension do WHMCS faz, e independente da ponte: os avisos saem pela API REST do painel, então uma instalação sem chave compartilhada ainda os manda, e uma sem token de API ainda espelha tickets.

Oito eventos do Paymenter, cada um com interruptor, canais e texto próprios:

Grupo Eventos
Faturas criada, paga, fechada
Serviços criado, suspenso, reativado, cancelado
Conta cliente novo

Service\Updated cobre três deles porque o Paymenter não tem evento de suspensão: a coluna de status mudando é o evento, e ler getChanges() é o único jeito de saber qual dos três aconteceu. Avisar em todo save diria ao cliente que o serviço dele mudou porque alguém corrigiu um typo no rótulo.

Tudo roda afterResponse(). Invoice\Paid dispara dentro do callback do gateway, e segurar esse callback enquanto o painel responde é como um pagamento vira "falhou" e é retentado.

Destinatário: do CLIENTE, canonizado pelo CONECTOR

Nada na config diz como falar com um cliente. A config diz qual conexão do UnifyDesk é o WhatsApp, qual é o Discord e qual é o e-mail. O endereço vem do cliente:

Canal De onde sai
WhatsApp propriedade do cliente — o Paymenter não tem campo de telefone
Discord o que o CLIENTE autorizou (OAuth), nunca um campo

E-mail não é canal do UnifyDesk. O Paymenter já manda e-mail — mailer, templates e fila dele — e passar isso por um conector daria à instalação dois sistemas que se acham donos da caixa do cliente. A página Notificar continua oferecendo e-mail; ela usa o do Paymenter.

Só o operador sabe como nomeou a propriedade do telefone, então a área admin pergunta a CHAVE dela. O valor cru passa por POST /api/tickets/resolve-target — a mesma chamada do "novo atendimento" do painel — e o que sai é a referência canônica, que é a que o inbound carrega. Mandar para outra grafia abre um SEGUNDO ticket quando o cliente responde. O resultado fica em cache por uma hora: é o cliente que muda o próprio cadastro, não nós.

Os três estados do contrato ficam separados: exists:true vale; error ou exists:false pula nomeando o motivo; exists ausente é não verificado e NÃO barra o envio — recusar numa consulta que não pôde rodar para mensagens a clientes perfeitamente alcançáveis.

Nenhuma frase escrita na tela

As três páginas pedem CHAVE, nunca texto: Lang::t('notify-send') no PHP, __('unifydesk::admin.notify-send') no blade. O dicionário mora em resources/lang/{en,pt}/admin.php e o inglês é a FONTE, como no resto do Paymenter — o admin dele traduz para fora, e uma extension que crava outro idioma na marcação renderiza português dentro de um painel em inglês, sem lugar onde traduzir depois. Idioma novo é PASTA, não código.

O namespace é registrado dentro do próprio Lang::t (na primeira chamada), e não no boot(), porque getConfig() é renderizado também para extension DESABILITADA — e extension desabilitada não dá boot. Registrar lá deixaria aquela tela imprimindo unifydesk::admin.config-agent para o operador, o que se lê como instalação quebrada.

O rótulo do evento saiu do Catalog: era a segunda cópia do mesmo nome, e duas cópias divergem. extensions/__tests__/php-packages.test.ts guarda os dois sentidos — chave que o código pede e não existe, e chave que existe num idioma só (inclusive as montadas, event-* e group-*, que vêm do catálogo).

Área "UnifyDesk" no admin

Página Filament em Admin/Pages/, que o Paymenter auto-registra para toda extension habilitada (discoverPages no AdminPanelProvider). Aparece em Extensions → UnifyDesk — o grupo onde o Paymenter arquiva o que configura uma extension; estava em Administration, ao lado de Users, que é onde se vai para trabalhar num CLIENTE. Três abas: Conexão, Canais e Eventos.

Existe porque o getConfig() de uma extension é uma lista PLANA de campos — suficiente para uma URL e uma chave, inútil para oito eventos com canal e corpo de mensagem cada. Uma página agrupa, e agrupar é toda a diferença entre uma tela que se configura e uma tela que se rola.

O que ela grava vai nos MESMOS settings que Extension::config() lê no boot(). Gravar em outro lugar significaria a tela salvando num sítio e os listeners lendo de outro — com "salvei e não aconteceu nada" como sintoma.

Discord: quem vincula é o cliente

O Paymenter tem login social com Discord — e descarta o id. SocialLoginController::handle() lê $oauth_user->email, casa a conta por e-mail e joga fora o resto: não há coluna, não há tabela e não há evento carregando o snowflake. Ou seja, "usa o id do login social" não tinha de onde ler.

Então a autorização é pedida de novo por nós (Discord/OAuth.php), com o MESMO app que o operador já configurou no Paymenter (settings.oauth_discord_*) — um segundo app seria um segundo client secret para guardar, pelo mesmo botão na mesma área de cliente. São dois passos no Developer Portal: adicionar https://<paymenter>/unifydesk/discord/callback como segunda redirect URI, e ligar User Install em Installation Contexts — sem ele o Discord recusa a URL de autorização.

Escopo identify guilds.join applications.commands, com integration_type=1. Os dois últimos existem pelo DM, não por cargo nem por comando: o Discord barra DM de bot com quem não divide servidor (erro 50278, "no mutual guilds"), e instalar o app na conta do cliente é o que dispensa esse servidor em comum. guilds.join continua junto porque a guild ainda é onde as regras de cargo entregam o que o cliente comprou — duas rotas independentes para a mesma caixa de entrada é a diferença entre fatura atrasada e fatura que nunca chega.

Guarda só o id e o handle (Discord/Links.php, como propriedade do cliente, chave FIXA unifydesk_discord_id). Sem access token, sem refresh, sem e-mail: o id é tudo que um DM precisa, e um token do Discord num banco de faturamento é credencial que ninguém pediu para a gente ter. O state é obrigatório — sem ele, um link de autorização preparado amarra o Discord do ATACANTE na conta da vítima, e daí em diante as faturas dela chegam no privado dele.

A entrada fica no menu de conta do cliente (evento navigation.account), e a ficha do cliente no admin mostra o estado.

Discord: cargo por serviço, e entrar no servidor junto

Vincular passou a valer mais do que um endereço de DM. Aba Discord em Extensions → UnifyDesk:

  • Regras de cargo (Discord/Rules.php): (quando, o quê, servidor, cargo) — "quem tem produto da categoria 3 ganha o cargo R no servidor G". Os tipos são as perguntas que um negócio de hospedagem realmente faz (é cliente? tem este produto? tem produto desta categoria? não é cliente?), então a regra se escreve nos termos do operador em vez de numa lista de snowflakes na mão. Ficam numa linha de settings (JSON), como os eventos: tabela exigiria migration para shippar, reverter no uninstall e responder por ela em todo upgrade, para meia dúzia de linhas de configuração.
  • É SYNC, não concessão: só cargo citado nas regras é REMOVIDO quando o cliente deixa de ter direito. Cargo de moderador, cor que alguém ganhou, cargo de outra integração não são da nossa conta — tirá-los porque um serviço venceu seria a extension vandalizando o servidor.
  • Entrar no servidor (Discord/Guilds.php, chave discord_auto_join): no callback do OAuth o cliente é puxado para os servidores que as regras citam, JÁ com o que ele tem direito. O escopo virou identify guilds.join — o bot não consegue puxar ninguém sozinho, e pedir o consentimento numa segunda tela é como um fluxo de dois cliques vira uma thread de suporte de dois dias. O access token vive só dentro da requisição: é usado uma vez e não é gravado.
  • Sem bot próprio (Discord/Bot.php): quem executa é o conector Discord do UnifyDesk, que já é um bot logado no servidor do operador e já declara list_guild_roles, join_guild e sync_roles. Um token nosso seria um segundo app, um segundo segredo num banco de faturamento e um segundo processo vivo, para o que o primeiro já faz. Chamada via Api\Client::command() → POST /api/extensions/:id/command.
  • Quando sincroniza: serviço criado, status mudou (suspenso/reativado/ cancelado) e no instante do vínculo. Sempre syncQuietly — cargo é efeito colateral de faturamento, e o Discord fora do ar não pode derrubar o checkout que pagou por ele. Desvincular devolve exatamente os cargos gerenciados, antes de o id sumir (depois não há mais com o que desfazer).

O quadro de cargos do painel mostra cargo que o bot NÃO consegue conceder (acima do cargo dele) em vez de escondê-lo: "Missing Permissions" no log se lê como módulo quebrado, e o motivo só é conhecível ali.

Entrar com Discord

GET /unifydesk/discord/login (chave discord_login) — coloque como link na sua tela de login. Quem já vinculou entra direto; o resto do fluxo:

  • A identidade é o VÍNCULO, não o e-mail. E-mail só casa uma conta existente na PRIMEIRA vez, e só quando o Discord diz que é verificado — endereço não verificado é campo que a pessoa digitou, e aceitá-lo entrega qualquer conta cujo e-mail alguém consiga pôr num Discord descartável.
  • Discord desconhecido com discord_register ligado vai para o cadastro do PRÓPRIO Paymenter com nome e e-mail preenchidos; o vínculo (e a entrada no servidor) acontece em User\Created. Criar a conta aqui seria inventar senha e campos que o formulário do Paymenter é dono de decidir.
  • O callback saiu de trás do auth (não há ninguém logado nesse fluxo) e o MODO vem da SESSÃO, nunca da query string: ele decide se a requisição pode logar alguém, e isso não pode depender de um valor que o navegador controla.
  • Quem já está logado sempre LINKA, venha de onde vier — logar de novo numa conta em que a pessoa já está é no melhor caso no-op e no pior uma troca de quem ela é.

Página "Notificar" — uma mensagem, muitos clientes

Segunda página Filament (Administration → Notificar). O Paymenter não tem área de disparo em massa: quem quisesse avisar de uma manutenção escrevia uma query ou dizia a mesma coisa em quatro lugares. É a ideia do "Mass Mail" do WHMCS com os canais que o UnifyDesk acrescenta, e mora aqui — não no Paymenter — porque só esta extension sabe se existe WhatsApp configurado.

Público como PERGUNTA, nunca como filtro livre: todos, com serviço ativo, sem serviço ativo, com fatura em aberto, clientes escolhidos. Filtro livre é mais poderoso e é também o que manda e-mail para quatro mil pessoas sem querer. O público é sempre um Builder — a contagem na tela e o envio saem da MESMA query, então o número que o operador leu é o número que recebe.

"Clientes escolhidos" é um select com BUSCA por nome ou e-mail, do jeito que o Paymenter busca usuário em qualquer outra tela. Digitar id significa abrir outra aba antes, e um id errado é uma mensagem para quem nunca deveria recebê-la.

Mensagens por minuto é campo da tela (padrão 10) e vale para WhatsApp e Discord. Mandar tudo de uma vez é o que faz uma plataforma ler o envio como spam — e a punição não é a mensagem recusada, é a conta bloqueada. O espaçamento sai do MESMO marcador que os avisos de faturamento usam: dois agendadores num número só é a rajada que nenhum dos dois quis mandar.

Antes do clique a tela diz três coisas, porque disparo não tem desfazer: quantos recebem, quanto TEMPO leva (dois mil clientes a 10/min são mais de três horas) e quais canais conseguem entregar agora. Canal sem conexão não aparece — checkbox que entrega em silêncio para ninguém é pior que checkbox ausente.

O aviso no painel é uma linha no banco (sem job); o e-mail vai pelo mailer do Paymenter; WhatsApp e Discord entram na fila um job por destinatário por canal, cada um com o próprio horário reservado.

Espaçamento: o cron não pode virar rajada

Support/Throttle.php. O WhatsApp é a razão: um número que dispara duzentas mensagens num minuto é lido como spam, e a punição não é a mensagem recusada — é a linha inteira banida, levando junto todas as conversas. O cron do Paymenter gera faturas em laço, então Invoice\Created sai uma vez por cliente, coladas.

Throttle::reserve($canal) devolve o atraso do próximo horário livre e move o marcador atomicamente (Cache::lock) — sem o lock dois workers leem o mesmo marcador e disparam juntos, que é exatamente a rajada que isto evita. Padrão: 6s no WhatsApp, 2s no Discord, configurável em Canais. E-mail não passa por aqui (o transporte dele já tem limite próprio).

UnifyDesk na ficha do cliente

Admin/Pages/ClientChannels.php — uma página na SUB-NAVEGAÇÃO do cliente, ao lado de Invoices, Services e Tickets, com a entrada injetada por render hook (panels::page.sub-navigation.sidebar.after, escopado às páginas do UserResource): UserResource é classe do Paymenter e getRecordSubNavigation() é método dela, então não há o que estender.

Era um bloco grudado no TOPO do formulário de edição, e isso estava errado duas vezes: empurrava o título da própria página para baixo, e respondia uma pergunta que não é "editar este usuário". Todo o resto que é assunto PRÓPRIO do cliente já é item daquela rail.

Responde duas perguntas que o resto do admin não responde:

  1. Esse cliente é alcançável? Um telefone que não é WhatsApp é invisível em qualquer outra tela: o cadastro parece normal e as mensagens simplesmente não chegam. A resposta fica ao lado do número, onde está quem pode corrigir.
  2. Falar com ele. Enviar mensagem é ação do HEADER, com modal do próprio Filament (canal + texto) — a caixa inline dentro do card era um formulário improvisado no meio de uma tela que tem um jeito de fazer isso. Abrir no UnifyDesk leva à conversa (/tickets/<ref> resolve pela referência do contato); o wa.me existe ao lado, mas só para OLHAR o número: responder por ali sairia do celular de quem clicou, fora do histórico.

Três estados, separados de propósito: verificado (o conector confirmou), não entrega (o conector recusou, em vermelho) e não verificado (neutro: a checagem não pôde rodar). Pintar o terceiro de vermelho ensina o atendente a ignorar o vermelho que importa.

Arquivos

  • php/Unifydesk/ — fonte canônica da extension do Paymenter. Editar aqui; a cópia dentro de uma instalação é o download.
    • Unifydesk.php — ponte de tickets, rotas, listeners de faturamento
    • Admin/Pages/ — as páginas que o Paymenter auto-registra (UnifyDesk, Notificar e a do cliente)
    • Discord/ — OAuth (vínculo e login), o id que ele guarda, as regras de cargo, a entrada no servidor e o bot por RPC no conector
    • Api/ Notify/ Support/ — cliente REST, catálogo/decisão/entrega/público, settings e espaçamento
  • src/php.ts — empacota a árvore e preenche Support/Install.php.
  • src/runtime.ts — conexão: estado, assinatura, entrada e saída.
  • src/extension.ts — manifest, rota /hook, blocks.
  • frontend/connector-settings.html — a tela dos três passos.

Versions

v3.1.1 1 week ago approved
v3.1.0 1 week ago approved
Developer
UnifyDesk
Downloads
1
Last updated
October 31, 2026
Price
Free
Latest version
3.1.1
Category
Extension
Instances
One per panel

Install

Install this extension from your UnifyDesk panel: Settings → Extensions → Install, then pick it from the Marketplace tab.

Extension id
paymenter