Importação dos produtos do checkout com o progresso do Apolo

TLDR: cada débito vindo do checkout passa a ter um ProductDebit por produto do pagamento, com external_id = products.slug do checkout. O progresso do aluno (percentual, módulos, certificado, validade) vem do Apolo por um novo service Apolo. O reimport completa os débitos já importados que ainda não têm produtos.

Asana: USER-01 — Importação inicial de devedores

Continuação de Importação dos pagamentos atrasados do checkout como débitos, que deixou ProductDebit fora de escopo.

Contexto

O Debits::ImportOverdueFromCheckout cria Debit e Installment, mas não cria ProductDebit. Sem produto, o débito não mostra o que o cliente comprou (DebitSerializer, DebitDetailSerializer, CustomerSerializer#products). E ele nunca fica elegível para negativação, porque Debit.eligible_for_negativation exige product_debits.consumer_progress >= 25 (Debit::MIN_PROGRESS_FOR_NEGATIVATION).

No checkout, o produto de um pagamento chega assim:

payments ──< payment_items >── checkouts ──> products └── checkout_id (checkout principal) ────┘

  • Um pagamento tem um payment_item por checkout comprado (principal + order bump). O checkout principal também está em payments.checkout_id.
  • checkouts.product_id é opcional.
  • products.slug é único e sempre preenchido na prática: o concern Slugable gera o slug a partir do name em todo before_validation, e a migration 20260909143000 preencheu os antigos. A coluna aceita nulo no banco.

O progresso do aluno não está no checkout: está no Apolo (LMS). O endpoint GET https://apolo.ibft.app/api/v2/students?email=<email> (Bearer token) devolve o aluno e as matrículas:

json { "data": [ { "id": 75690, "email": "...", "enrollments": [ { "transactionCode": "payment_dfd86facef6aae990e39431790515812", "transactionOrigin": "checkout", "courseSlug": "formacao-de-terapeutas-trg", "courseName": "Formação de Terapeutas - TRG", "status": "enabled", "active": true, "originalExpiresAt": "2027-09-28T00:00:59.000-03:00", "expiresAt": "2027-09-28T00:00:59.000-03:00", "createdAt": "2026-09-27T10:30:58.790-03:00", "certificateIssuedAt": null, "progressPercentage": 3.8, "completedModules": 1, "totalModules": 26, "currentModule": { "id": 81, "name": "02 MÓDULO CULTURAL", "position": 2, "progressPercentage": 20.0 } } ] } ] }

courseSlug do Apolo é sempre igual ao products.slug do checkout. É por ele que a matrícula casa com o ProductDebit.

Objetivos

  • Criar, para cada débito importado, um ProductDebit por produto distinto do pagamento.
  • Gravar products.slug em external_id e products.name em product_name.
  • Preencher o progresso do ProductDebit com a matrícula do Apolo de mesmo slug.
  • Integrar o Apolo como service (app/services/apolo/), no formato do commons:rails.
  • No reimport, criar os produtos dos débitos já importados que ainda não têm nenhum.

Fora de escopo

  • Atualizar o progresso de um ProductDebit que já existe (sync recorrente com o Apolo).
  • Criar produto para checkout sem product_id: o item é ignorado.
  • Usar transactionCode para casar a matrícula. Ele é o payments.reference, mas num reparcelamento aponta para o pagamento original, e o casamento por slug cobre os dois casos.
  • Qualquer escrita no Apolo.
  • Mudar a regra de elegibilidade para negativação.

Regras

Produtos do pagamento

  • Os produtos de um pagamento são os de payment_items.checkout_id mais o de payments.checkout_id, via checkouts.product_id → products.
  • Checkout sem product_id é ignorado. Se nenhum checkout do pagamento tem produto, o débito é importado sem ProductDebit.
  • Produto repetido (dois checkouts do mesmo produto) gera um só ProductDebit, o que respeita o índice único (debit_id, product_name).
  • external_id = products.slug. Se o slug vier vazio, usa products.pid.
  • product_name = products.name.

Leitura no checkout

Os produtos da página são lidos em uma consulta a mais por página, e não uma por pagamento:

sql SELECT payment_items.payment_id, products.id, products.name, products.slug, products.pid FROM payment_items INNER JOIN checkouts ON checkouts.id = payment_items.checkout_id INNER JOIN products ON products.id = checkouts.product_id WHERE payment_items.payment_id IN (:payment_ids) UNION SELECT payments.id, products.id, products.name, products.slug, products.pid FROM payments INNER JOIN checkouts ON checkouts.id = payments.checkout_id INNER JOIN products ON products.id = checkouts.product_id WHERE payments.id IN (:payment_ids)

O resultado é agrupado por payment_id e deduplicado por products.id, na ordem de products.id.

Progresso vindo do Apolo

  • Uma chamada Apolo.find_student(email:) por e-mail do cliente do checkout, fora da transação do débito. O resultado fica em memória pela execução inteira, então o mesmo e-mail não é consultado duas vezes.
  • Cliente sem e-mail não consulta o Apolo.
  • A matrícula de um produto é a de courseSlug == external_id. Com mais de uma, vale a active; entre elas, a de createdAt mais recente.
  • Mapeamento da matrícula para o ProductDebit:

    ProductDebit Apolo Observação
    consumer_progress progressPercentage floor, limitado a 0..100. floor para 24,9% não virar 25% e liberar negativação
    watched_lessons completedModules  
    total_lessons totalModules  
    certificate_issued certificateIssuedAt presente  
    expires_on expiresAt como data em Brasília  
    lifetime expiresAt nulo  
  • Sem aluno, sem matrícula do slug ou com o Apolo fora do ar, o ProductDebit é criado com os defaults (progresso 0, sem validade) e o débito é importado normalmente. Falha do Apolo é logada e contada em apolo_failures na execução, mas não vai para failed.
  • completedModules > totalModules é limitado a totalModules, para passar na validação watched_lessons_within_total.

Reimport

  • Hoje o reimport pula o débito já importado (already_imported). Agora, se esse débito não tem nenhum ProductDebit, os produtos dele são criados, com o progresso do Apolo, e o pagamento conta em products_backfilled. Ele continua em skipped com o motivo already_imported.
  • Débito já importado que já tem algum ProductDebit não é tocado.
  • Débito, parcelas e cliente de quem já foi importado continuam sem alteração.

Service Apolo

Formato do commons:rails (§ Services), primeiro service do app:

  • Apolo.find_student(email:) devolve Apolo::Student ou nil quando data vem vazio.
  • Apolo::Student = Data.define(:id, :enrollments) e Apolo::Enrollment = Data.define(:course_slug, :active, :progress_percentage, :completed_modules, :total_modules, :certificate_issued_at, :expires_at, :created_at). As chaves do Apolo (courseSlug, progressPercentage) só aparecem dentro de services/apolo/.
  • Apolo::Error, Apolo::Unavailable (timeout, conexão, 5xx) e Apolo::Rejected (4xx, incluindo 401).
  • Apolo::Client é o único lugar com Faraday: a base URL vem de ENV["APOLO_URL"] (default https://apolo.ibft.app) e o token de ENV["APOLO_API_TOKEN"], com timeout de abertura de 5s e de leitura de 10s. Faraday::Error nunca sai de services/apolo/.
  • O e-mail vai na query string, como o endpoint exige. Nada do payload do Apolo é logado.

Mudanças

Checkout (somente leitura)

  • db/checkout_schema.rb: adicionar products (name, slug, pid, organization_id) e payment_items (payment_id, checkout_id), e a coluna checkouts.product_id.
  • app/models/checkout/product.rb: Checkout::Product com o scope for_payments(payment_ids), que roda a consulta da seção Leitura no checkout e devolve payment_id junto de cada produto.
  • app/models/checkout/payment_item.rb: Checkout::PaymentItem, usado pelos fixtures.

Apolo

  • Gemfile: faraday e, no grupo test, webmock.
  • app/services/apolo.rb, app/services/apolo/client.rb, app/services/apolo/error.rb, app/services/apolo/unavailable.rb, app/services/apolo/rejected.rb, app/services/apolo/student.rb, app/services/apolo/enrollment.rb.
  • .env.example: APOLO_URL e APOLO_API_TOKEN. O token é segredo e não entra em arquivo versionado: vem do ambiente.

Import

  • app/use_cases/debits/import_overdue_from_checkout.rb:
    • import_page carrega os produtos da página com Checkout::Product.for_payments;
    • o marcador already_imported passa a guardar o id do débito e se ele já tem produtos;
    • antes da transação, busca o aluno no Apolo pelo e-mail (com o cache da execução);
    • na transação, cria os ProductDebit depois das parcelas;
    • no already_imported sem produtos, cria só os ProductDebit, numa transação própria;
    • o resumo ganha products_backfilled e apolo_failures.
  • A tradução da matrícula do Apolo para os atributos do ProductDebit (tabela de mapeamento) é um método privado do use case (progress_attributes). O ProductDebit não conhece o Apolo nem Apolo::Enrollment.
  • Migration: checkout_import_runs.products_backfilled_count e checkout_import_runs.apolo_failures_count (inteiros, default 0), preenchidos em finish.

Testes

  • test/fixtures/checkout/products.yml e test/fixtures/checkout/payment_items.yml, e product_id em test/fixtures/checkout/checkouts.yml. Cobrem: checkout com produto, checkout sem produto, pagamento com order bump (2 produtos) e dois itens do mesmo produto.
  • test/services/apolo_test.rb com WebMock: aluno com matrículas, data vazio (nil), 401 → Rejected, 500 e timeout → Unavailable, e o header Authorization.
  • test/models/checkout/product_test.rb: for_payments junta payment_items e o checkout principal e ignora checkout sem produto.
  • test/use_cases/debits/import_overdue_from_checkout_test.rb, com Apolo.find_student stubado:
    • 1 produto com progresso do Apolo;
    • order bump gera 2 ProductDebit;
    • produto repetido gera 1;
    • checkout sem produto importa o débito sem ProductDebit;
    • slug sem matrícula no Apolo e Apolo::Unavailable criam o produto com defaults e contam em apolo_failures só no segundo caso;
    • o mesmo e-mail em dois pagamentos consulta o Apolo uma vez;
    • reimport de débito sem produtos cria os produtos e conta em products_backfilled;
    • reimport de débito com produtos não cria nada;
    • mapeamento do progresso: floor de 24,9 → 24, limite 0..100, completedModules > totalModules, expiresAt nulo → lifetime e certificado.

Ajustes feitos na implementação

  • Os erros do Apolo ficam em três arquivos (error.rb, unavailable.rb, rejected.rb): o Zeitwerk espera Apolo::Errors em errors.rb e não autocarrega Apolo::Unavailable de lá.
  • payment_items não ganhou model no app: a tabela só é lida pelo SQL de Checkout::Product.for_payments. O fixture usa CheckoutFixturePaymentItem, em test/support.
  • A base URL do Apolo vem de ENV["APOLO_URL"], com https://apolo.ibft.app quando vazia. O token vem de ENV["APOLO_API_TOKEN"], e não das credentials.
  • A escolha da matrícula (a ativa primeiro, depois a mais recente) fica no use case, porque é decisão de negócio.

Como verificar

  1. bin/rails t em modules/backend passa.
  2. Em development, com CHECKOUT_DATABASE_URL apontando para o banco local do checkout e com APOLO_URL e APOLO_API_TOKEN preenchidos:
    • bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now';
    • num débito amostrado, product_debits.pluck(:external_id) bate com products.slug dos checkouts do pagamento, e o consumer_progress bate com o progressPercentage do Apolo para aquele slug;
    • rodar de novo dá imported: 0 e products_backfilled: 0;
    • apagar os ProductDebit de um débito e rodar de novo recria só eles (products_backfilled: 1).

Documentação

  • .project/docs/rules/collections/checkout_overdue_import.md: novas regras R-009.12 (produtos do pagamento e external_id), R-009.13 (progresso do Apolo e mapeamento) e R-009.14 (reimport cria os produtos que faltam).
  • .project/docs/architecture/backend_layers.md: tirar a frase “Hoje services/ está vazio” e citar Apolo como o primeiro service.
  • .project/docs/specs/20260928171636_import_overdue_checkout_payments.md: no “Fora de escopo”, apontar ProductDebit para esta spec.
  • .project/docs/README.md: registrar esta spec e o futuro plano.