Slug em organização e produto
TLDR:
organizationseproductsganharam a colunaslug, derivada do nome, para identificar empresa e produto na sincronização de reparcelamento com oibft-backendsem precisar de tabela de-para entre os dois sistemas. A migration faz o backfill de todos os registros existentes, resolvendo colisão e nome sem caractere aproveitável.
Contexto
O checkout-api está em depreciação, mas vai rodar em paralelo ao novo checkout (ibft-pay + ibft-backend) por um período.
Reparcelamento não é operação de checkout — é operação financeira. Até existir um módulo financeiro no produto novo, a funcionalidade permanece no checkout-api e passa a sincronizar o resultado com o ibft-backend, que é onde o financeiro consulta e opera. O contrato do endpoint que o outro time implementa está em ../reference/payments/new_checkout_repayment_endpoint.md.
A necessidade
O payload da sincronização precisa dizer para qual empresa e para qual produto o reparcelamento vai. A venda original nasceu aqui e não existe no ibft-backend, então não há nada no payload que resolva isso sozinho — algum identificador tem que trafegar.
Levantamento nos dois schemas:
| Entidade | checkout-api |
ibft-backend |
|---|---|---|
| Empresa | organizations: name, sem slug, sem CNPJ |
Company: code, name, document, sem slug |
| Produto | products: name (único por organização), pid, sem slug |
Product: code, name, slug (único por empresa, opcional) |
As alternativas eram trocar UUID entre os sistemas — o que exige uma tabela de-para mantida à mão — ou criar slug nos dois lados. O slug ganha: é derivado do nome, então o preenchimento é backfill automático em vez de digitação, o valor é legível, o erro é visível na conferência e sobrevive à recriação do registro em qualquer um dos lados. Product.slug do ibft-backend já existe exatamente para isso: o help_text do campo diz que é o “identificador amigável enviado como product_id nas integrações”.
Checkout e CustomFieldOption já usam o concern Slugable, que gera o slug a partir do nome com String#parameterize. A mesma regra passa a valer para organização e produto — e é ela que está documentada como regra canônica no contrato, para o ibft-backend reproduzir.
Escopo da fase 1 da sincronização
| O que | Fase 1 |
|---|---|
company_slug |
ibft e citrg |
product_slug |
só CITRG. Reparcelamento da IBFT vai sem o campo e cai no produto legado da empresa, no destino |
Isso não muda o que foi implementado — o backfill preenche tudo de uma vez — mas define o que precisa estar conferido antes do go-live: ibft e citrg têm que sair exatamente com esses valores.
Objetivos
organizations.slug, único global.products.slug, único global — mesmo entre organizações diferentes. Oibft-backendresolveproduct_slugdentro da empresa (ver contrato), então unicidade global aqui é uma restrição mais forte do que o contrato exige, não uma incompatibilidade — e evita o slug de produto virar identificador ambíguo caso o escopo por empresa mude de lado sem os dois times se coordenarem.- Backfill automático de todos os registros na migration, sem intervenção manual.
- Slug gerado automaticamente em registro novo, e normalizado quando digitado à mão.
- Sem limite de tamanho —
parameterizepuro, igual ao resto do concernSlugable.
Fora de escopo
- O disparo do webhook de reparcelamento, a pré-checagem de organização sem slug e a ação de reenvio — vêm depois, na implementação da sincronização.
- Coluna equivalente em
Companye backfill deProduct.slugnoibft-backend: é entrega do outro time.
Decisões
Reaproveitar Slugable em vez de criar outra regra. O concern já existe e é usado por dois modelos. Criar uma segunda convenção de slug no mesmo código seria pior que aceitar a semântica do parameterize, que é estável e cobre acento, símbolo, espaço repetido e caixa.
Sem truncamento. Slugable não ganhou limite de tamanho — organização e produto usam o parameterize puro, igual a Checkout e CustomFieldOption. Risco aceito: o Product.slug do ibft-backend tem max_length=100, então um nome que gere slug com mais de 100 caracteres nos dois lados diverge (o nosso não trunca, o de lá trunca ou rejeita) e a resolução por slug falha para esse registro específico — caso considerado raro o suficiente para não justificar reintroduzir a complexidade do truncamento.
Backfill dentro da migration, usando Organization/Product da aplicação. Os dois modelos já existem — não faz sentido redeclarar classes ActiveRecord::Base locais só para reler id/name. A migration ainda não usa o Slugable (nem save/valid?): lê os registros pelos models reais e grava o slug com update_all, então continua imune a validação de presença de outros campos (ex.: gateway_token em Organization) e ao comportamento do concern.
Colisão no backfill resolvida por sufixo numérico. Curso A.1 e Curso A 1 geram o mesmo slug. O backfill acrescenta -2, -3 e assim por diante dentro do escopo de unicidade.
Sem fallback por id, nem para organização nem para produto. Colocar o id no slug quebraria a leitura por convenção que o apolo já faz hoje (lista de slugs existente, alinhada por nome via parameterize). Nome sem nenhum caractere aproveitável (@@@) simplesmente cai na resolução por sufixo a partir de string vazia (-2, -3, …) — caso raro que o inventário de conferência antes do go-live (ver seção Pendente) cobre.
Colisão em registro novo falha a validação. Fora do backfill, slug repetido é erro de validação e o operador ajusta o campo no formulário. Resolver por sufixo automático exigiria mexer no Slugable compartilhado, mudando o comportamento de Checkout e CustomFieldOption — não vale o risco para um caso raro.
Coluna anulável. Igual a checkouts.slug. O índice único do Postgres aceita múltiplos NULL, e a validação de unicidade usa allow_blank. Organização sem slug simplesmente não sincroniza, e a pré-checagem do disparo trata isso quando for implementada.
O que foi feito
| Arquivo | Mudança |
|---|---|
db/migrate/20260909143000_add_slug_to_organizations_and_products.rb |
Colunas, backfill e índices únicos, nesta ordem — o índice depois do backfill, senão estouraria no meio |
app/models/organization.rb |
include Slugable, unicidade global com allow_blank |
app/models/product.rb |
include Slugable, unicidade global com allow_blank |
app/admin/organizations.rb, app/admin/products.rb |
Slug no show e no formulário, com hint — sem isso ninguém vê nem corrige um slug |
spec/models/organization_spec.rb |
Derivação, normalização, estabilidade e colisão |
spec/models/product_spec.rb |
Novo arquivo, mesmos casos, colisão testada dentro e entre organizações (unicidade global) |
Comportamento resultante:
- Slug é gerado do nome quando vazio, e normalizado quando digitado à mão.
- Slug não muda quando o nome muda — é identificador estável, e mudá-lo quebraria a resolução do outro lado.
- O backfill imprime a contagem por tabela via
say_with_time.
Regra canônica
A mesma dos dois lados, replicada no contrato para o ibft-backend implementar em Python:
- Transliterar para ASCII, descartando acento.
- Minúsculas.
- Toda sequência fora de
[a-z0-9_]vira um único-. - Remover
-das pontas.
Sem truncamento — o parameterize não limita tamanho, e o contrato foi atualizado para não exigir isso do outro lado.
O _ é preservado, e . vira separador — é o que o parameterize faz, e é por isso que o contrato manda não usar django.utils.text.slugify no destino: para Curso v1.0 o Django devolve curso-v10 e esta regra devolve curso-v1-0.
Validação
Feito:
standardrblimpo nos arquivos alterados.- Algoritmo do backfill verificado em teste standalone: acento, ponto, underscore preservado, separador repetido, nome sem caractere útil e cadeia de colisão (
-2,-3). run/migrate— migration aplicada, com o backfill reportando0 rowsnas duas tabelas no banco local vazio.run/test—spec/models/organization_spec.rbespec/models/product_spec.rbverdes, 32 exemplos.
Removidos no processo: os matchers validate_uniqueness_of(:slug) dos dois specs. Eles não provam a regra — o shoulda grava o registro existente com as validações desligadas, o que pula o before_validation de normalização, e os valores comparados deixam de bater. A unicidade continua coberta pelos testes com registros reais.
Pendente:
- Inventário de slug em produção antes da migration, conferindo colisão, slug vazio e nome muito longo (sem truncamento, o slug sai do tamanho do nome inteiro), e confirmando que
ibftecitrgsaem com esses valores exatos. Colisão dentro do escopo de unicidade é resolvida pelo sufixo, masibft-2como slug de organização quebraria o combinado da fase 1.
Referências
- ../reference/payments/new_checkout_repayment_endpoint.md — contrato do endpoint, com a regra canônica de slug e o escopo da fase 1
app/models/concerns/slugable.rb— o concern reaproveitadoapp/services/checkout_payments/duplicate_service.rb,repayment_service.rb— a operação que vai passar a notificar oibft-backend