Slug em organização e produto

TLDR: organizations e products ganharam a coluna slug, derivada do nome, para identificar empresa e produto na sincronização de reparcelamento com o ibft-backend sem 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. O ibft-backend resolve product_slug dentro 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 — parameterize puro, igual ao resto do concern Slugable.

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 Company e backfill de Product.slug no ibft-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:

  1. Transliterar para ASCII, descartando acento.
  2. Minúsculas.
  3. Toda sequência fora de [a-z0-9_] vira um único -.
  4. 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:

  • standardrb limpo 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 reportando 0 rows nas duas tabelas no banco local vazio.
  • run/test — spec/models/organization_spec.rb e spec/models/product_spec.rb verdes, 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 ibft e citrg saem com esses valores exatos. Colisão dentro do escopo de unicidade é resolvida pelo sufixo, mas ibft-2 como 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 reaproveitado
  • app/services/checkout_payments/duplicate_service.rb, repayment_service.rb — a operação que vai passar a notificar o ibft-backend