Uma leitura guiada pelas 20 commits que substituÃram um sistema de pagamentos caseiro por assinaturas nativas da Stripe — do modelo de dados ao botão que o usuário clica quando fica sem créditos.
Antes de entrar no que mudou, vale alinhar dois vocabulários: o da Stripe (para quem nunca integrou um provedor de pagamentos) e o do próprio IA Solaris (para entender o que existia antes desta reforma).
A Stripe é uma processadora de pagamentos. Para não lidar diretamente com número de cartão, senha do PIX ou boleto, um sistema como o IA Solaris usa três peças prontas da Stripe:
POST que a Stripe envia para uma URL sua, de forma assÃncrona — o usuário já pode ter fechado a aba antes desse aviso chegar.200 a tempo (por lentidão, erro, ou timeout), a Stripe tenta de novo — e de novo. Isso significa que todo código que reage a um webhook precisa ser idempotente: processar o mesmo evento duas vezes não pode conceder créditos em dobro.
O IA Solaris é um fork do LibreChat. No histórico do projeto, o branch de produção (main) havia acumulado, ao longo do tempo, uma camada de faturamento inteiramente própria — arquivos prefixados *SOL, com sua própria interface de pagamento construÃda sobre @stripe/react-stripe-js, gerenciamento de assinatura escrito à mão, e o OpenPix como provedor externo só para aceitar Pix (o meio de pagamento instantâneo brasileiro). Segundo o ADR que documenta essa decisão, esse sistema já somava "centenas de arquivos, dezenas de serviços", com webhooks duplicados chegando tanto da Stripe quanto do OpenPix e precisando ser reconciliados entre si.
Este branch especÃfico — vanilla, a partir do qual esta diferença foi calculada — parte do LibreChat "de fábrica": nenhum faturamento embutido, apenas um modelo Balance simples com um campo tokenCredits e um mecanismo de auto-recarga genérico que nada sabia sobre assinaturas.
A decisão registrada no ADR-0002 foi: descartar toda a camada *SOL e reconstruir o faturamento usando apenas primitivas nativas da Stripe — porque o próprio Checkout da Stripe já aceita cartão, boleto e Pix nativamente, tornando o OpenPix e qualquer interface de pagamento própria simplesmente desnecessários. As 20 commits que vamos percorrer, de 2034826b7 a 2b8ce5b86, são exatamente essa reconstrução — evoluindo em várias iterações visÃveis no próprio histórico: um modelo de crédito único, depois duas cestas, depois um espelho de assinatura, depois uma fila assÃncrona para os webhooks, depois reconciliação do dia 1, e por fim um ajuste fino para planos anuais.
No fundo, esta funcionalidade inteira responde a uma única pergunta toda vez que alguém manda uma mensagem no chat: "este usuário pode gastar tokens agora?" Webhooks, espelhos, filas e CLIs administrativas existem só para manter essa resposta correta, rápida, e resiliente ao fato de que a Stripe pode entregar o mesmo aviso mais de uma vez.
O documento Balance de cada usuário guarda dois números independentes, não um só:
A cesta mensal vem do plano de assinatura: é reposta a cada invoice.paid e funciona no regime "use ou perca" — o que sobra é descartado na próxima renovação. A cesta comprada vem de pacotes avulsos (top-ups) e se acumula para sempre. Quando um gasto acontece, ele drena primeiro a cesta mensal (badge ①) e só invade a comprada (badge ②) se a mensal não for suficiente — essa é a lógica de updateBalance, em transaction.ts.
resolveEntitlement — decide se o pedido passa. Uma assinatura active ou trialing libera o acesso independentemente do saldo de créditos; sem isso, cai no fallback de somar as duas cestas e checar se é maior que zero. Se nada disso vale, a API responde 402 Payment Required com um corpo já indicando os próximos passos ({ topup: '/api/checkout', plans: '/api/plans' }), para que o cliente saiba exatamente para onde mandar o usuário.
Aqui mora o primeiro quebra-cabeça de qualquer integração com Stripe: um evento de webhook carrega apenas identificadores da própria Stripe (id do cliente, da assinatura, da sessão) — nunca o id de usuário do seu banco. É preciso construir essa correlação por conta própria.
A convenção de metadata.ts resolve o primeiro evento: ao criar a sessão de checkout, o servidor carimba { userId, tipo, planKey } nos metadados. Isso funciona perfeitamente para checkout.session.completed — mas só para ele.
invoice.paid, customer.subscription.updated e todo evento posterior chegam sem nenhum userId nos metadados — usá-los ali seria simplesmente errado.
A solução é o espelho de assinatura (subscription mirror): um subdocumento em Balance.subscription que guarda uma cópia local de { stripeSubscriptionId, stripeCustomerId, planKey, status, currentPeriodEnd }. A partir do segundo evento em diante, a correlação passa a ser feita pelo stripeCustomerId — que a Stripe garante estar presente em praticamente todo evento — em vez de depender de metadados. E como a identidade do plano também pode mudar (um usuário troca de plano pelo Customer Portal), o plano nunca é lido de metadados desatualizados: ele é sempre re-derivado do preço (price) atual da assinatura, a fonte de verdade que a própria Stripe mantém correta.
Uma decisão de arquitetura que atravessa todo o motor de faturamento: a rota /api/webhook não processa nada de fato. Ela só valida a assinatura criptográfica do evento, empilha o evento cru numa coleção Mongo (billing_events) e responde 200 em menos de 100ms.
Um processo separado — o worker — fica de prontidão (sondando a cada segundo, mas também "acordado" imediatamente após cada novo evento chegar) e vai reivindicando um evento por vez com um "aluguel" (lease) de 60 segundos, processando-o, e então marcando como concluÃdo ou reagendando com atraso se o processamento falhar ou pedir uma nova tentativa explicitamente.
Por que separar assim, em vez de processar tudo dentro do handler HTTP? Duas preocupações que puxam em direções opostas: responder rápido à Stripe (para ela não pensar que o webhook falhou e reenviar sem necessidade) versus tolerar que o processamento real precise de nova tentativa — por exemplo, um invoice.paid pode chegar antes mesmo do espelho de assinatura existir, e nesse caso o certo é tentar de novo em instantes, não descartar o evento nem travar a resposta HTTP esperando.
Todo esse mecanismo de espelho funciona perfeitamente — a partir do momento em que existe. Mas no dia em que essa funcionalidade foi ao ar, a Stripe já tinha assinantes reais (o ADR cita 19 assinantes pré-existentes do fork), que nunca passaram por um Checkout local e portanto não tinham nenhuma linha de espelho para convergir.
A resposta é uma rotina de reconciliação única (não recorrente): ela lista todas as assinaturas ativas na Stripe, e para cada uma tenta casar por e-mail — a única chave disponÃvel quando não existe espelho ainda — com um usuário local. Quando encontra exatamente um usuário correspondente, chama a mesma função (registerSubscriptionMirror) que o fluxo de webhook normal usaria; o resultado final é indistinguÃvel de uma assinatura que tivesse passado pelo checkout local desde o inÃcio. Casos ambÃguos (dois usuários com o mesmo e-mail, nenhum usuário encontrado, plano não reconhecÃvel) são coletados numa lista, não descartados silenciosamente — para um administrador poder resolver manualmente.
Um problema mais sutil apareceu só depois que planos de ciclo longo (semestral, anual) entraram em produção: a Stripe dispara invoice.paid uma vez por ciclo de cobrança — e para um plano anual, esse ciclo é de 12 meses. Só que monthlyCredits deveria significar "créditos por mês", não "créditos por ano pago de uma vez".
2b8ce5b86.
A solução evita inventar um mecanismo novo do zero: o LibreChat já tinha, de antes, um recurso de auto-recarga nativa (usado originalmente para o saldo gratuito padrão). O handler de invoice.paid passou a também escrever autoRefillEnabled: true, refillAmount: monthlyCredits, refillIntervalUnit: 'months', refillIntervalValue: 1 no Balance. Dali em diante, é o mecanismo já existente de recarga preguiçosa (que roda dentro de checkBalance.ts, no momento de cada gasto) que preenche a lacuna entre uma fatura e outra.
A parte nova de fato nesta última commit é o portão de segurança: a recarga automática só é aplicada se a assinatura mirror ainda estiver com status active ou trialing no momento do gasto. Sem esse cuidado, um assinante que cancelasse continuaria recebendo recargas mensais para sempre, porque nada mais na base zeraria aqueles campos de auto-recarga.
Uma usuária começa o mês com 2000 créditos mensais (do plano) e 500 créditos comprados anteriormente.
Gasto de 300 créditos numa conversa:
A cesta mensal absorve tudo sozinha (min(2000, 300) = 300), então a comprada nem é tocada.
Em seguida, um gasto maior — 2200 créditos numa única chamada:
A mensal cobre min(1700, 2200) = 1700, sobrando 500 de custo; a comprada absorve o restante até zerar (max(0, 500 - 500) = 0). As duas cestas mudam na mesma escrita atômica no Mongo.
Chega a renovação mensal (invoice.paid, plano concede 2000/mês de novo) — mas, hipoteticamente, se a usuária ainda tivesse 300 créditos mensais sobrando antes da renovação, a cesta mensal seria sobrescrita para 2000 (não somada), e aqueles 300 restantes virariam um recibo de expiredCredits: 300 mais uma linha negativa de "expiração" no extrato — a cesta comprada nunca é tocada por uma renovação.
E se a Stripe reenviar o mesmo evento invoice.paid (o que ela faz por padrão, "at-least-once")? O filtro creditReceipts.externalId != externalId na própria escrita do Mongo já rejeita a segunda tentativa antes mesmo de tocar o saldo — a operação retorna duplicate: true e nada muda.
Agora que o modelo mental está montado, vale ver onde cada peça mora — organizado da camada mais interna (dados) para a mais externa (o que o usuário vê na tela).
packages/data-schemas)O esquema final de Balance (schema/balance.ts) reúne tudo que discutimos acima num único documento:
tokenCredits // cesta mensal do plano — reseta a cada invoice.paid
purchaseTokenCredits // cesta de compras avulsas — nunca expira
creditReceipts[] // trilha de auditoria/idempotência: {externalId, operation, credits, expiredCredits}
subscription {
stripeSubscriptionId, stripeSessionId, stripeCustomerId,
planKey, status, // 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing'
currentPeriodEnd
}
methods/subscription.ts concentra a lógica do espelho: registerSubscriptionMirror cria a primeira versão (no checkout, armazenando provisoriamente o id da sessão como se fosse o id da assinatura, até o invoice.paid substituir pelo real), e syncSubscription converge todo evento seguinte — convergindo pelo stripeCustomerId, nunca pelo id da assinatura sozinho, exatamente porque o valor provisório citado acima poderia não bater. Uma regra é absoluta: canceled é terminal — nenhum evento atrasado ou fora de ordem depois de um cancelamento pode reviver o espelho.
methods/credits.ts traz as duas operações de crédito: grantCredits (sempre na cesta comprada, guardada contra duplicidade pelo filtro de creditReceipts) e resetMonthlyCredits (sobrescreve a cesta mensal, calculando quanto expirou no mesmo passo atômico). methods/transaction.ts contém o updateBalance que já vimos — o dreno mensal-primeiro — dentro de um laço de repetição otimista (até 10 tentativas, com espera exponencial) que detecta escritas concorrentes comparando o valor lido com o valor atual antes de aplicar.
Por fim, methods/billing.ts não mexe com dinheiro — é a implementação da fila durável de eventos (billing_events): inserir com deduplicação por eventId, reivindicar com aluguel, concluir ou reagendar.
packages/api/src/billing)Esta é a parte mais densa da mudança — webhooks.ts sozinho cresceu para mais de 600 linhas e foi tocado por quase todas as 20 commits. Um mapa de funções lida com cada tipo de evento:
| Evento Stripe | O que faz |
|---|---|
checkout.session.completedcheckout.session.async_payment_succeeded | Se for assinatura: cria o espelho (registerSubscriptionMirror). Se for compra avulsa: credita a cesta comprada — só depois de confirmar payment_status como pago (cobre também o caso de cupom de 100% de desconto, que nunca gera uma cobrança de fato). |
checkout.session.async_payment_failed | Não faz nada além de registrar em log — pagamentos assÃncronos (boleto, Pix) que falham nunca chegaram a conceder crédito, então não há nada a desfazer. |
invoice.paid | O handler mais complexo: reseta a cesta mensal, escreve os campos de auto-recarga (§2.5) e promove o espelho para active. |
invoice.payment_failed | Marca o espelho como past_due. |
customer.subscription.updated | Traduz o status da Stripe para o enum local e re-deriva o plano a partir do preço — nunca da metadata — cobrindo trocas de plano feitas pelo Customer Portal. |
customer.subscription.deleted | Marca o espelho como canceled (terminal). |
| Qualquer outro tipo | Confirmado (200) e ignorado — nenhum efeito colateral. |
checkout.ts guarda um detalhe fácil de deixar passar: resolveCheckoutCustomer tenta reutilizar um stripeCustomerId já existente para aquele usuário antes de criar uma nova sessão. Sem isso, um usuário que já comprou um pacote avulso e depois assina um plano acabaria com dois objetos de cliente na Stripe — e, segundo o próprio comentário no código, "sem nenhum caminho de código para cancelar o órfão". portal.ts é bem mais simples: apenas embrulha a chamada de criação de sessão do Customer Portal, deixando para o chamador garantir que o customerId pertence de fato ao usuário autenticado.
reconciliation.ts implementa exatamente o processo descrito em §2.4. worker.ts implementa o laço de sondagem/reivindicação de §2.3 — sondando a cada segundo por padrão, mas também podendo ser "acordado" imediatamente. E checkBalance.ts ganhou a condição extra do plano anual (§2.5):
const subscriptionAllowsRefill =
record.subscription != null &&
SUBSCRIPTION_ACCESS_STATUSES.includes(record.subscription.status);
// a auto-recarga nunca deve sobreviver à assinatura que a originou
Duas salvaguardas de segurança valem destaque: toda requisição de webhook passa primeiro por uma verificação de assinatura criptográfica (constructEvent da própria Stripe) sobre o corpo cru da requisição — falhar aqui derruba o evento antes de qualquer processamento. E, ao invés de simplesmente tipar o payload do evento como Record<string, unknown> (o que o guia de estilo do projeto evita explicitamente), o código define uma interface nomeando só os campos que de fato lê, e valida cada um com typeof antes de confiar neles.
api/server/routes, config/)| Rota | Guarda | Função |
|---|---|---|
POST /api/checkout | JWT + billing | Cria uma sessão de Checkout (assinatura ou compra avulsa) — o preço é sempre resolvido no servidor, nunca aceito do cliente. |
GET /api/checkout/packages | JWT + billing | Lista os pacotes avulsos configurados. |
GET /api/plans | JWT + billing | Lista os planos de assinatura configurados. |
POST /api/portal | JWT + billing | Cria uma sessão do Customer Portal para o próprio usuário autenticado (403 se ele não tiver um stripeCustomerId). |
POST /api/webhook | nenhuma (é a Stripe chamando) | Verifica a assinatura, enfileira o evento, acorda o worker. Não processa nada de fato. |
POST /api/admin/billing/reconcile | admin + billing | Dispara a reconciliação do dia 1 (§2.4) sob demanda. |
POST /api/admin/billing/balance | admin (sem exigir billing ativo) | Ajuste manual de saldo — grant (crédito na cesta comprada) ou zero — para suporte corrigir uma conta mesmo com a Stripe desligada. |
O middleware gate.js — o portão de acesso de §2.1 — não é aplicado globalmente. Ele é conectado ponto a ponto, logo antes de cada controlador que efetivamente gera uma resposta e gasta créditos: no router de chat dos agentes, no endpoint de chat/completions estilo OpenAI, no endpoint de Responses, e nas subrotas /v1/chat e /v2/chat de assistentes — deliberadamente não nas rotas de listagem/CRUD desses mesmos routers.
Além da API, existem dois scripts de linha de comando equivalentes às rotas administrativas — pensados para quem tem acesso ao servidor mas não quer passar pela interface de admin: npm run billing-balance -- <email> <grant|zero> [créditos] e npm run billing-reconcile, ambos reaproveitando exatamente as mesmas funções que as rotas HTTP chamam.
client/src)O pedaço mais visÃvel para quem usa o produto no dia a dia é a reescrita de Error.tsx — o componente que hoje aparece quando uma mensagem não pode ser enviada por falta de crédito.
Antes, esse erro mostrava um texto técnico cru ("Insufficient funds. Balance: X. Prompt tokens: Y...") e, à s vezes, um bloco de JSON de depuração — sem nenhuma ação possÃvel. Agora, dois tipos de erro diferentes — token_balance (fica sem créditos no meio da conversa) e billing_gate (é barrado antes de começar, pelo portão de acesso) — passam pelo mesmo componente TokenBalanceMessage, mudando só a frase inicial. Os dois trazem um botão primário para comprar o pacote de créditos marcado como "destaque" (ou o maior disponÃvel, se nenhum estiver marcado) e um link secundário que abre a aba de faturamento das Configurações, já rolada até a seção certa.
Esse "abrir direto na seção de faturamento" só foi possÃvel graças a um pequeno refactor da caixa de diálogo de Configurações: Dialog.tsx ganhou props initialTab/initialSection, e cada seção passou a expor um atributo data-settings-section que o diálogo usa para rolar até o lugar certo com scrollIntoView. Uma nova BillingShellContext guarda esse estado de "abrir configurações em tal aba" no nÃvel do app inteiro (montada em Root.tsx), para que o banner de erro do chat — um componente de "folha", bem distante da árvore de Configurações — consiga acionar isso sem precisar importar nada da store global. É uma aplicação direta de uma regra do próprio guia de estilo do repositório: estado "global mas apenas consumido" deve ser recebido via contexto/props, não buscado direto de ~/store, para manter a funcionalidade portátil.
Dentro da própria aba de faturamento, dois componentes ficam lado a lado: TopUpCredits (pacotes avulsos, com um selo "Melhor custo-benefÃcio" no destaque) e o novo SubscriptionPlans — que mostra um botão "Assinar · N créditos/mês" por plano, ou, se o usuário já tiver uma assinatura ativa ou em teste, um único botão "Gerenciar assinatura" que leva ao Customer Portal. Essa regra — assinante existente sempre vai para o Portal, nunca de volta para um novo Checkout — está documentada no próprio código com uma referência direta ao ADR-0002.
Uma commit isolada no meio da série (25303ec76) não tem nada a ver com faturamento — é uma correção de bug encontrada de passagem. O código de login via Facebook acessava profile.emails[0]?.value: o ?. protegia contra o Ãndice [0] ser inválido, mas não contra o próprio array emails não existir — e alguns perfis do Facebook simplesmente não trazem esse array, quebrando o login com um TypeError. A correção move o encadeamento opcional para antes do Ãndice (profile.emails?.[0]?.value). De quebra, o escopo do OAuth solicitado ao Facebook ganhou 'email' — que, olhando o código anterior, nunca havia sido pedido, então o Facebook jamais estaria autorizado a devolver o e-mail em primeiro lugar — e profileFields ganhou 'photos', então o avatar do usuário passou a ser importado também.
--apply em produção. É a continuação natural da história: pouco depois de o "truque do plano anual" (§2.5) entrar em produção, apareceu um efeito colateral sutil dele mesmo.
A configuração de planos mudou de uma progressão escalonada — mensal com 160.000, semestral com 960.000 (≈160k/mês), anual com 1.600.000 (≈133k/mês) — para um valor único e mais alto: 4.000.000 de créditos por mês em todos os planos, independentemente do ciclo de cobrança. A partir de agora, o que diferencia os planos entre si é apenas o preço e o compromisso de permanência — não mais o volume de créditos.
Duas outras constantes andaram junto: o saldo inicial de cadastro (balance.startBalance) subiu de 20.000 para 500.000, e o exemplo genérico de auto-recarga em librechat.example.yaml — o mecanismo "de fábrica" do LibreChat, sem relação com assinaturas — passou de "30 dias / 10.000 créditos" para "1 mês / 4.000.000 créditos", alinhando o exemplo não-relacionado a faturamento com o número que a própria assinatura agora usa, para não deixar dois valores tão diferentes representando conceitualmente a mesma coisa no mesmo arquivo de configuração.
O mecanismo de recarga automática descrito em §2.5 funciona de forma preguiçosa: ele só é checado dentro de checkBalance.ts, no momento em que alguém tenta gastar créditos — não existe nenhum agendador rodando em segundo plano. Isso tem uma consequência que passou despercebida até agora: toda vez que uma recarga automática efetivamente disparava, o código gravava lastRefill: new Date() — o instante exato em que a recarga foi aplicada, não o instante em que ela deveria ter acontecido.
Como o gatilho depende de quando o usuário volta a gastar créditos, esses dois instantes podem se afastar um do outro a cada ciclo. Se uma pessoa demora alguns dias para voltar a conversar depois que sua data de pagamento já passou, a recarga só dispara nesse gasto atrasado — e, ao gravar "agora" como a nova âncora, a próxima data de elegibilidade também nasce deslocada daquele atraso. Em vez de ficar sempre fixada no dia real do pagamento, a âncora vai "escorregando" ciclo após ciclo.
status_transitions.paid_atpaid_at real, ou o calendário mensal correto quando a recarga é preguiçosaA correção tem duas metades, uma em cada ponta do fluxo:
webhooks.ts, handleInvoicePaid): o handler agora lê invoice.status_transitions.paid_at — o timestamp que a própria Stripe grava no momento exato em que o pagamento se confirma — e grava esse valor diretamente como lastRefill. Um comentário no código é explÃcito sobre o motivo: "Do not use the webhook event's created time: delivery can happen after payment." A diferença fica mais visÃvel justamente nos métodos assÃncronos que o ADR-0002 trouxe para dentro do Checkout — Pix e boleto —, em que a liquidação e a entrega do webhook podem ficar visivelmente separadas no tempo.checkBalance.ts): duas funções novas, getMonthlyRefillDate e getLatestMonthlyRefillDate, fazem aritmética de calendário — somam meses inteiros preservando o dia original do pagamento (com um ajuste para meses mais curtos: 31 de janeiro mais um mês vira 28 de fevereiro, não "3 de março"). Quando uma recarga preguiçosa finalmente dispara, mesmo que atrasada, o valor gravado em lastRefill não é mais "agora" — é a fronteira mensal correta que já deveria ter passado, calculada a partir da âncora original. Um atraso de alguns dias no gasto do usuário deixa de contaminar todos os ciclos seguintes.A conexão entre as duas pontas passa por um parâmetro novo e discreto: createAutoRefillTransaction (em transaction.ts) ganhou um campo opcional refillAt em TxData — quando presente, ele substitui o new Date() que antes era gravado incondicionalmente em Balance.lastRefill. É uma mudança cirúrgica: qualquer transação que não seja uma recarga automática continua funcionando exatamente como antes.
Subir a nova configuração (4.000.000 para todos os planos) resolve o problema apenas dali para frente. Quem já era assinante ativo continua com tokenCredits travado no valor antigo e só receberia os novos 4 milhões no próximo invoice.paid natural — que, para um assinante anual, pode estar a quase um ano de distância.
config/billing-reset-active.js (com a lógica de datas isolada em billing-reset-active-helpers.js, testada separadamente) faz o seguinte, por assinatura com subscription.status: 'active':
status_transitions.paid_at dela — pulando (sem adivinhar) qualquer assinatura em que esse dado não esteja disponÃvel ou pareça estar no futuro.latestMonthlyAnchor(paidAt, now) — a mesma aritmética de calendário de §4.2, só que extraÃda como uma função pura e testada de forma independente (por exemplo: um pagamento em 31 de janeiro, verificado em março, ancora em 31 de março — não em fevereiro).resetMonthlyCredits e upsertBalanceFields que o webhook usaria, com um externalId próprio (cli:<operationId>:<subscriptionId>) — o estado final fica indistinguÃvel do que um invoice.paid correto teria produzido.purchaseTokenCredits nunca é escrito pelo script — apenas exibido no log, para conferência. Por padrão, roda em modo de simulação (não grava nada); é preciso passar --apply explicitamente para de fato alterar algo, e um --operation-id opcional serve de trilha de auditoria caso o script precise rodar mais de uma vez. Até o momento desta leitura, ele foi preparado e testado, mas ainda não executado com --apply em produção.
Cinco perguntas para verificar se o modelo mental grudou. Clique numa alternativa para ver se acertou.
checkout.session.completed, mas via o espelho de assinatura (pelo stripeCustomerId) em eventos posteriores como invoice.paid?invoice.paid a cada 6-12 meses, mas monthlyCredits deveria significar créditos por mês. Como a última commit resolveu isso sem criar nenhuma lógica nova do lado da Stripe?