Importação dos produtos do checkout com o progresso do Apolo — Plano de implementação

TLDR: service Apolo (Faraday + WebMock), ProductDebit.progress_from, leitura dos produtos do pagamento no checkout (Checkout::Product.for_payments), contadores novos em checkout_import_runs e o use case Debits::ImportOverdueFromCheckout criando e completando os ProductDebit.

Spec: .project/docs/specs/20260928232447_import_checkout_products.md Branch: feat/import_products

Arquitetura: o Apolo entra como primeiro módulo de app/services/, no formato do commons:rails: operações de módulo, value objects Data.define e erros Apolo::Unavailable/Apolo::Rejected, com Faraday só em Apolo::Client. O use case continua sendo o único orquestrador. Ele lê os produtos da página numa consulta a mais no checkout, busca o aluno no Apolo antes da transação (com cache por e-mail) e cria os ProductDebit junto com o débito, ou sozinhos no reimport.

Stack: Rails 8.1, u-case, Minitest com fixtures YAML (sem factory), Faraday, WebMock.

Restrições globais

  • Testes rodam com bin/rails t dentro de modules/backend, nunca com make.
  • Nada de factory: todo dado de teste vem de fixture YAML ou de create! inline.
  • O banco do checkout é somente leitura (CheckoutRecord#readonly?).
  • O vocabulário do Apolo (courseSlug, progressPercentage) só aparece em app/services/apolo/ e app/services/apolo.rb.
  • Faraday::Error nunca sai de app/services/apolo/.
  • Commits: uma linha, até 60 caracteres, sem menção a IA. Pergunte antes de cada commit.

Ajustes em relação à spec

Task 2 superada: depois da implementação, o mapeamento progress_from saiu do ProductDebit e virou o método privado progress_attributes do use case, para o model não conhecer o Apolo. O código da Task 2 abaixo é o histórico da primeira versão.

  • payment_items não ganha model no app: o app só lê a tabela pelo SQL de Checkout::Product.for_payments. O fixture usa CheckoutFixturePaymentItem em test/support/checkout_fixture_records.rb, o mesmo padrão já usado por checkouts e organizations.
  • Apolo::Client usa https://apolo.ibft.app quando ENV["APOLO_URL"] está vazio. A URL não é segredo, e assim o teste não depende das credentials. O token continua só nas credentials.
  • A escolha da matrícula (a ativa primeiro, depois a mais recente) fica no use case, e não no service, porque é decisão de negócio.

Os três ajustes entram na spec na Task 6.


Mapa de arquivos

Arquivo Ação Responsabilidade
modules/backend/Gemfile / Gemfile.lock Modify faraday; webmock no grupo test
modules/backend/test/test_helper.rb Modify webmock/minitest e stub padrão do Apolo (aluno inexistente)
modules/backend/app/services/apolo.rb Create Apolo.find_student(email:) e a tradução do payload
modules/backend/app/services/apolo/client.rb Create HTTP (Faraday), auth, timeouts e erros
modules/backend/app/services/apolo/{error,unavailable,rejected}.rb Create Apolo::Error, Unavailable, Rejected (um por arquivo, por causa do Zeitwerk)
modules/backend/app/services/apolo/student.rb Create Apolo::Student = Data.define(:id, :enrollments)
modules/backend/app/services/apolo/enrollment.rb Create Apolo::Enrollment = Data.define(...)
modules/backend/test/services/apolo_test.rb Create Service com WebMock
modules/backend/app/models/product_debit.rb Modify ProductDebit.progress_from(enrollment)
modules/backend/test/models/product_debit_test.rb Modify Testes do mapeamento
modules/backend/db/checkout_schema.rb Modify products, payment_items e checkouts.product_id
modules/backend/app/models/checkout/product.rb Create Checkout::Product.for_payments(payment_ids)
modules/backend/test/support/checkout_fixture_records.rb Modify CheckoutFixturePaymentItem
modules/backend/test/fixtures/checkout/products.yml Create Produtos do checkout
modules/backend/test/fixtures/checkout/payment_items.yml Create Itens dos pagamentos
modules/backend/test/fixtures/checkout/checkouts.yml Modify product_id e checkouts novos
modules/backend/test/models/checkout/product_test.rb Create for_payments
modules/backend/db/migrate/20260928233500_add_product_counts_to_checkout_import_runs.rb Create products_backfilled_count, apolo_failures_count
modules/backend/app/models/checkout_import_run.rb + test/fixtures/checkout_import_runs.yml + test/models/checkout_import_run_test.rb Modify Anotação de schema
modules/backend/app/use_cases/debits/import_overdue_from_checkout.rb Modify Produtos, Apolo e backfill
modules/backend/app/jobs/import_overdue_checkout_payments_job.rb Modify Log dos contadores novos
modules/backend/test/use_cases/debits/import_overdue_from_checkout_test.rb Modify Casos novos
.project/docs/... Modify Regras, arquitetura, specs e índice

Ordem e paralelismo

Task 1 (Apolo service) ──> Task 2 (ProductDebit.progress_from) ──┐ Task 3 (Checkout::Product + fixtures) ───────────────────────────┼──> Task 5 (use case) ──> Task 6 (docs) Task 4 (migration dos contadores) ───────────────────────────────┘

As Tasks 1, 3 e 4 são independentes e podem rodar em paralelo. A Task 2 depende da 1 (Apolo::Enrollment). A Task 5 depende de todas.


Task 1: Service Apolo

Files: - Modify: modules/backend/Gemfile, modules/backend/Gemfile.lock - Modify: modules/backend/test/test_helper.rb - Create: modules/backend/app/services/apolo.rb - Create: modules/backend/app/services/apolo/client.rb - Create: modules/backend/app/services/apolo/error.rb, unavailable.rb e rejected.rb - Create: modules/backend/app/services/apolo/student.rb - Create: modules/backend/app/services/apolo/enrollment.rb - Test: modules/backend/test/services/apolo_test.rb

Interfaces: - Consumes: nada. - Produces: - Apolo.find_student(email:) -> Apolo::Student | nil - Apolo::Student = Data.define(:id, :enrollments), com enrollments sendo Array<Apolo::Enrollment> - Apolo::Enrollment = Data.define(:course_slug, :active, :progress_percentage, :completed_modules, :total_modules, :certificate_issued_at, :expires_at, :created_at). Os tempos são ActiveSupport::TimeWithZone ou nil, progress_percentage é Float e os módulos são Integer. - Apolo::Error < StandardError, Apolo::Unavailable < Apolo::Error, Apolo::Rejected < Apolo::Error - Stub global em test_helper.rb: todo GET /api/v2/students responde { "data": [] }, a menos que o teste declare outro stub

  • [ ] Step 1: Adicionar as gems

Em modules/backend/Gemfile, depois do bloco do kaminari:

ruby # gem for HTTP calls to third parties (services/) https://github.com/lostisland/faraday gem "faraday"

Criar um grupo test no fim do Gemfile, ou adicionar a um que já exista:

ruby group :test do # stubs HTTP requests in tests https://github.com/bblimke/webmock gem "webmock" end

bash cd modules/backend && bundle install

  • [ ] Step 2: Ligar o WebMock e o stub padrão do Apolo

modules/backend/test/test_helper.rb:

```ruby ENV[“RAILS_ENV”] ||= “test” require_relative “../config/environment” require “rails/test_help” require “minitest/mock” require “webmock/minitest”

Dir[File.expand_path(“support/*/.rb”, dir)].each { file require file }

module ActiveSupport class TestCase # Run tests in parallel with specified workers parallelize(workers: :number_of_processors)

# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all

# Apolo answers "student not found" unless a test stubs it otherwise (later stubs win).
setup do
  stub_request(:get, %r{/api/v2/students}).to_return(status: 200, body: { data: [] }.to_json)
end   end end ```
  • [ ] Step 3: Escrever o teste que falha

modules/backend/test/services/apolo_test.rb:

```ruby require “test_helper”

class ApoloTest < ActiveSupport::TestCase STUDENTS_URL = %r{/api/v2/students}

def student_body(*enrollments) { data: [ { id: 75_690, email: “aluna@example.com”, enrollments: enrollments } ] }.to_json end

def trg_enrollment { transactionCode: “payment_dfd86facef6aae990e39431790515812”, courseSlug: “formacao-de-terapeutas-trg”, courseName: “Formação de Terapeutas - TRG”, status: “enabled”, active: true, expiresAt: “2027-09-28T00:00:59.000-03:00”, createdAt: “2026-09-27T10:30:58.790-03:00”, certificateIssuedAt: nil, progressPercentage: 3.8, completedModules: 1, totalModules: 26 } end

test “finds the student by email with enrollments in our vocabulary” do stub_request(:get, STUDENTS_URL).with(query: { email: “aluna@example.com” }) .to_return(status: 200, body: student_body(trg_enrollment))

student = Apolo.find_student(email: "aluna@example.com")

assert_equal 75_690, student.id
enrollment = student.enrollments.sole
assert_equal "formacao-de-terapeutas-trg", enrollment.course_slug
assert enrollment.active
assert_in_delta 3.8, enrollment.progress_percentage
assert_equal 1, enrollment.completed_modules
assert_equal 26, enrollment.total_modules
assert_nil enrollment.certificate_issued_at
assert_equal Date.new(2027, 9, 28), enrollment.expires_at.to_date
assert_equal Date.new(2026, 9, 27), enrollment.created_at.to_date   end

test “returns nil when the student does not exist” do assert_nil Apolo.find_student(email: “ninguem@example.com”) end

test “sends the bearer token from credentials” do request = stub_request(:get, %r{\Ahttps://apolo.test/api/v2/students}) .with(headers: { “Authorization” => “Bearer secret-token” }) .to_return(status: 200, body: { data: [] }.to_json) credentials = { [ :apolo, :api_url ] => “https://apolo.test”, [ :apolo, :api_token ] => “secret-token” }

Rails.application.credentials.stub(:dig, ->(*keys) { credentials[keys] }) do
  Apolo.find_student(email: "aluna@example.com")
end

assert_requested request   end

test “raises Rejected on a 4xx” do stub_request(:get, STUDENTS_URL).to_return(status: 401, body: { error: “unauthorized” }.to_json)

assert_raises(Apolo::Rejected) { Apolo.find_student(email: "aluna@example.com") }   end

test “raises Unavailable on a 5xx” do stub_request(:get, STUDENTS_URL).to_return(status: 502, body: “Bad Gateway”)

assert_raises(Apolo::Unavailable) { Apolo.find_student(email: "aluna@example.com") }   end

test “raises Unavailable on a timeout” do stub_request(:get, STUDENTS_URL).to_timeout

assert_raises(Apolo::Unavailable) { Apolo.find_student(email: "aluna@example.com") }   end

test “raises Unavailable on an invalid JSON body” do stub_request(:get, STUDENTS_URL).to_return(status: 200, body: “<html>”)

assert_raises(Apolo::Unavailable) { Apolo.find_student(email: "aluna@example.com") }   end end ```
  • [ ] Step 4: Rodar e ver falhar

bash cd modules/backend && bin/rails t test/services/apolo_test.rb

Esperado: FAIL — NameError: uninitialized constant ApoloTest::Apolo.

  • [ ] Step 5: Implementar

modules/backend/app/services/apolo/errors.rb:

ruby module Apolo class Error < StandardError; end # Timeout, connection failure, 5xx or an unreadable body — worth retrying. class Unavailable < Error; end # 4xx — Apolo refused the request (e.g. an invalid token). class Rejected < Error; end end

modules/backend/app/services/apolo/student.rb:

ruby module Apolo Student = Data.define(:id, :enrollments) end

modules/backend/app/services/apolo/enrollment.rb:

ruby module Apolo Enrollment = Data.define( :course_slug, :active, :progress_percentage, :completed_modules, :total_modules, :certificate_issued_at, :expires_at, :created_at ) end

modules/backend/app/services/apolo/client.rb:

```ruby module Apolo # The only place Faraday appears. Base URL and token come from credentials.apolo. module Client DEFAULT_URL = “https://apolo.ibft.app”

def self.get(path, params)
  response = connection.get(path, params)
  raise Rejected, "status #{response.status}" if response.status.between?(400, 499)
  raise Unavailable, "status #{response.status}" unless response.success?

  JSON.parse(response.body)
rescue Faraday::Error => e
  raise Unavailable, e.class.name
rescue JSON::ParserError
  raise Unavailable, "invalid JSON body"
end

def self.connection
  Faraday.new(
    url: Rails.application.credentials.dig(:apolo, :api_url).presence || DEFAULT_URL,
    headers: {
      "Authorization" => "Bearer #{Rails.application.credentials.dig(:apolo, :api_token)}",
      "Content-Type" => "application/json"
    },
    request: { open_timeout: 5, timeout: 10 }
  )
end
private_class_method :connection   end end ```

modules/backend/app/services/apolo.rb:

```ruby # Apolo (LMS): students and their course enrollments. module Apolo def self.find_student(email:) row = Client.get(“/api/v2/students”, email: email).fetch(“data”, []).first return if row.nil?

Student.new(id: row["id"], enrollments: Array(row["enrollments"]).map { |enrollment| build_enrollment(enrollment) })   end

def self.build_enrollment(row) Enrollment.new( course_slug: row[“courseSlug”], active: row[“active”] == true, progress_percentage: row[“progressPercentage”].to_f, completed_modules: row[“completedModules”].to_i, total_modules: row[“totalModules”].to_i, certificate_issued_at: parse_time(row[“certificateIssuedAt”]), expires_at: parse_time(row[“expiresAt”]), created_at: parse_time(row[“createdAt”]) ) end

def self.parse_time(value) = value.present? ? Time.zone.parse(value) : nil

private_class_method :build_enrollment, :parse_time end ```

  • [ ] Step 6: Rodar e ver passar, com a suíte inteira

bash cd modules/backend && bin/rails t test/services/apolo_test.rb && bin/rails t

Esperado: PASS, e a suíte continua com 0 falhas (o WebMock não quebra nenhum teste existente).

  • [ ] Step 7: Commit (perguntar antes)

bash git add modules/backend/Gemfile modules/backend/Gemfile.lock modules/backend/test/test_helper.rb \ modules/backend/app/services modules/backend/test/services git commit -m "feat: adiciona service Apolo para buscar alunos"


Task 2: ProductDebit.progress_from

Files: - Modify: modules/backend/app/models/product_debit.rb - Test: modules/backend/test/models/product_debit_test.rb

Interfaces: - Consumes: Apolo::Enrollment (Task 1). Na prática, qualquer objeto que responda aos mesmos métodos. - Produces: ProductDebit.progress_from(enrollment) -> Hash. Devolve {} para nil; senão devolve as chaves consumer_progress, watched_lessons, total_lessons, certificate_issued, expires_on e lifetime.

  • [ ] Step 1: Escrever os testes que falham

Acrescentar ao fim de ProductDebitTest, antes do end da classe:

```ruby def apolo_enrollment(overrides) Apolo::Enrollment.new({ course_slug: “formacao-de-terapeutas-trg”, active: true, progress_percentage: 3.8, completed_modules: 1, total_modules: 26, certificate_issued_at: nil, expires_at: Time.zone.parse(“2027-09-28T00:00:59-03:00”), created_at: Time.zone.parse(“2026-09-27T10:30:58-03:00”) }.merge(overrides)) end

test “progress_from maps the Apolo enrollment” do assert_equal({ consumer_progress: 3, watched_lessons: 1, total_lessons: 26, certificate_issued: false, expires_on: Date.new(2027, 9, 28), lifetime: false }, ProductDebit.progress_from(apolo_enrollment)) end

test “progress_from floors the percentage so 24.9 stays below the negativation threshold” do assert_equal 24, ProductDebit.progress_from(apolo_enrollment(progress_percentage: 24.9))[:consumer_progress] end

test “progress_from keeps the percentage within 0..100” do assert_equal 100, ProductDebit.progress_from(apolo_enrollment(progress_percentage: 130.0))[:consumer_progress] assert_equal 0, ProductDebit.progress_from(apolo_enrollment(progress_percentage: -1.0))[:consumer_progress] end

test “progress_from limits completed modules to the total” do progress = ProductDebit.progress_from(apolo_enrollment(completed_modules: 30, total_modules: 26))

assert_equal 26, progress[:watched_lessons]   end

test “progress_from treats a missing expiration as lifetime access” do progress = ProductDebit.progress_from(apolo_enrollment(expires_at: nil))

assert progress[:lifetime]
assert_nil progress[:expires_on]   end

test “progress_from marks the certificate when Apolo has an issue date” do progress = ProductDebit.progress_from(apolo_enrollment(certificate_issued_at: Time.zone.parse(“2026-09-01T10:00:00-03:00”)))

assert progress[:certificate_issued]   end

test “progress_from returns no attributes without an enrollment” do assert_equal({}, ProductDebit.progress_from(nil)) end ```

  • [ ] Step 2: Rodar e ver falhar

bash cd modules/backend && bin/rails t test/models/product_debit_test.rb

Esperado: FAIL — NoMethodError: undefined method 'progress_from' for class ProductDebit.

  • [ ] Step 3: Implementar

Em modules/backend/app/models/product_debit.rb, depois de validate :watched_lessons_within_total:

```ruby # Progress attributes from an Apolo enrollment. The percentage is floored so 24.9% never # reaches Debit::MIN_PROGRESS_FOR_NEGATIVATION. def self.progress_from(enrollment) return {} if enrollment.nil?

total = [ enrollment.total_modules.to_i, 0 ].max
{
  consumer_progress: enrollment.progress_percentage.to_f.floor.clamp(0, 100),
  watched_lessons: enrollment.completed_modules.to_i.clamp(0, total),
  total_lessons: total,
  certificate_issued: enrollment.certificate_issued_at.present?,
  expires_on: enrollment.expires_at&.to_date,
  lifetime: enrollment.expires_at.nil?
}   end ```
  • [ ] Step 4: Rodar e ver passar

bash cd modules/backend && bin/rails t test/models/product_debit_test.rb

Esperado: PASS.

  • [ ] Step 5: Commit (perguntar antes)

bash git add modules/backend/app/models/product_debit.rb modules/backend/test/models/product_debit_test.rb git commit -m "feat: mapeia progresso do Apolo no ProductDebit"


Task 3: Produtos do pagamento no checkout

Files: - Modify: modules/backend/db/checkout_schema.rb - Create: modules/backend/app/models/checkout/product.rb - Modify: modules/backend/test/support/checkout_fixture_records.rb - Create: modules/backend/test/fixtures/checkout/products.yml - Create: modules/backend/test/fixtures/checkout/payment_items.yml - Modify: modules/backend/test/fixtures/checkout/checkouts.yml - Test: modules/backend/test/models/checkout/product_test.rb

Interfaces: - Consumes: nada. - Produces: - Checkout::Product.for_payments(payment_ids) -> Array<Checkout::Product>. Cada registro tem payment_id, id, name, slug e pid, sem par (payment_id, product) repetido, em ordem de payment_id, id. - Fixtures, usadas pela Task 5:

| Pagamento (`checkout/payments.yml`) | Checkouts | Produtos esperados (`external_id`) |
|---|---|---|
| `overdue_standard` (`ref_maria`) | itens `ibft_course` + `ibft_bump` | `formacao-de-terapeutas-trg`, `formacao-em-leitura-corporal-e-comportamental` |
| `overdue_rafael` (`ref_rafael`) | itens `ibft_course` + `ibft_promo` (mesmo produto) | `formacao-de-terapeutas-trg` |
| `overdue_renewal` (`ref_admin_lead`) | principal `ibft_course` + item `ibft_legacy` (produto sem slug) | `formacao-de-terapeutas-trg`, `prod_sem_slug` |
| `overdue_repayment` (`ref_joana`) | item `outra_course` (sem produto) | nenhum |
| `overdue_settlement`, `overdue_boundary_after` (Carlos) | só o principal `ibft_course`, sem itens | `formacao-de-terapeutas-trg` |
  • [ ] Step 1: Espelhar as tabelas no schema do checkout de teste

Em modules/backend/db/checkout_schema.rb, dentro de create_table "checkouts", depois de t.bigint "organization_id", default: 1, null: false:

ruby t.bigint "product_id"

E, depois do bloco de checkouts, as tabelas novas:

```ruby create_table “products”, force: :cascade do |t| t.string “name” t.string “slug” t.string “pid” t.bigint “organization_id”, null: false t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“slug”], unique: true end

create_table “payment_items”, force: :cascade do |t| t.bigint “payment_id”, null: false t.bigint “checkout_id”, null: false t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“payment_id”, “checkout_id”], unique: true end ```

bash cd modules/backend && bin/rails db:test:prepare

  • [ ] Step 2: Fixtures

modules/backend/test/support/checkout_fixture_records.rb, acrescentar:

ruby class CheckoutFixturePaymentItem < CheckoutRecord self.table_name = "payment_items" end

modules/backend/test/fixtures/checkout/products.yml:

```yaml _fixture: model_class: Checkout::Product

<% ibft = ActiveRecord::FixtureSet.identify(:ibft) %>

trg: name: Formação de Terapeutas - TRG slug: formacao-de-terapeutas-trg pid: prod_trg organization_id: <%= ibft %>

leitura: name: Formação em Leitura Corporal e Comportamental slug: formacao-em-leitura-corporal-e-comportamental pid: prod_leitura organization_id: <%= ibft %>

sem_slug: name: Produto Sem Slug pid: prod_sem_slug organization_id: <%= ibft %> ```

modules/backend/test/fixtures/checkout/checkouts.yml (arquivo inteiro):

```yaml _fixture: model_class: CheckoutFixtureCheckout

<% ibft = ActiveRecord::FixtureSet.identify(:ibft) %>

ibft_course: name: Curso IBFT slug: curso-ibft organization_id: <%= ibft %> product_id: <%= ActiveRecord::FixtureSet.identify(:trg) %>

ibft_bump: name: Order bump Leitura Corporal slug: bump-leitura organization_id: <%= ibft %> product_id: <%= ActiveRecord::FixtureSet.identify(:leitura) %>

ibft_promo: name: Curso IBFT promocional slug: curso-ibft-promo organization_id: <%= ibft %> product_id: <%= ActiveRecord::FixtureSet.identify(:trg) %>

ibft_legacy: name: Checkout legado slug: checkout-legado organization_id: <%= ibft %> product_id: <%= ActiveRecord::FixtureSet.identify(:sem_slug) %>

outra_course: name: Curso Outra slug: curso-outra organization_id: <%= ActiveRecord::FixtureSet.identify(:outra) %> ```

modules/backend/test/fixtures/checkout/payment_items.yml:

```yaml _fixture: model_class: CheckoutFixturePaymentItem

<% item = ->(label) { ActiveRecord::FixtureSet.identify(label) } %>

maria_main: payment_id: <%= item.(:overdue_standard) %> checkout_id: <%= item.(:ibft_course) %>

maria_bump: payment_id: <%= item.(:overdue_standard) %> checkout_id: <%= item.(:ibft_bump) %>

rafael_main: payment_id: <%= item.(:overdue_rafael) %> checkout_id: <%= item.(:ibft_course) %>

rafael_promo: payment_id: <%= item.(:overdue_rafael) %> checkout_id: <%= item.(:ibft_promo) %>

admin_lead_legacy: payment_id: <%= item.(:overdue_renewal) %> checkout_id: <%= item.(:ibft_legacy) %>

joana_main: payment_id: <%= item.(:overdue_repayment) %> checkout_id: <%= item.(:outra_course) %> ```

  • [ ] Step 3: Escrever o teste que falha

modules/backend/test/models/checkout/product_test.rb:

```ruby require “test_helper”

module Checkout class ProductTest < ActiveSupport::TestCase TRG = “formacao-de-terapeutas-trg” LEITURA = “formacao-em-leitura-corporal-e-comportamental”

def slugs_by_payment(*labels)
  ids = labels.to_h { |label| [ checkout_payments(label).id, label ] }
  Checkout::Product.for_payments(ids.keys)
    .group_by { |product| ids.fetch(product.payment_id) }
    .transform_values { |products| products.map { |product| product.slug || product.pid }.sort }
end

test "joins payment items and the main checkout, one row per product" do
  assert_equal(
    { overdue_standard: [ TRG, LEITURA ].sort, overdue_rafael: [ TRG ],
      overdue_renewal: [ TRG, "prod_sem_slug" ].sort, overdue_settlement: [ TRG ] },
    slugs_by_payment(:overdue_standard, :overdue_rafael, :overdue_renewal, :overdue_settlement, :overdue_repayment)
  )
end

test "leaves out checkouts without a product" do
  assert_empty Checkout::Product.for_payments([ checkout_payments(:overdue_repayment).id ])
end

test "returns nothing without payments" do
  assert_empty Checkout::Product.for_payments([])
end   end end ```
  • [ ] Step 4: Rodar e ver falhar

bash cd modules/backend && bin/rails t test/models/checkout/product_test.rb

Esperado: FAIL — NameError: uninitialized constant Checkout::Product (o fixture products.yml também não carrega sem o model).

  • [ ] Step 5: Implementar

modules/backend/app/models/checkout/product.rb:

```ruby module Checkout class Product < CheckoutRecord self.table_name = “products”

# Products of each payment: the checkouts of its payment_items plus its main checkout.
# Checkouts without a product are left out; UNION drops the repeated (payment, product) rows.
# Each record carries the payment_id it belongs to.
def self.for_payments(payment_ids)
  return [] if payment_ids.empty?

  find_by_sql([ <<~SQL, { ids: payment_ids } ])
    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 (:ids)
    UNION
    SELECT payments.id AS payment_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 (:ids)
    ORDER BY payment_id, id
  SQL
end   end end ```
  • [ ] Step 6: Rodar e ver passar, com a suíte inteira

bash cd modules/backend && bin/rails t test/models/checkout/product_test.rb && bin/rails t

Esperado: PASS. A suíte inteira continua verde; os fixtures novos não mudam o import atual.

  • [ ] Step 7: Commit (perguntar antes)

bash git add modules/backend/db/checkout_schema.rb modules/backend/app/models/checkout/product.rb \ modules/backend/test/support/checkout_fixture_records.rb modules/backend/test/fixtures/checkout \ modules/backend/test/models/checkout/product_test.rb git commit -m "feat: lê os produtos dos pagamentos do checkout"


Task 4: Contadores novos em checkout_import_runs

Files: - Create: modules/backend/db/migrate/20260928233500_add_product_counts_to_checkout_import_runs.rb - Modify: modules/backend/db/schema.rb (gerado) - Modify: anotações em app/models/checkout_import_run.rb, test/models/checkout_import_run_test.rb e test/fixtures/checkout_import_runs.yml (geradas)

Interfaces: - Consumes: nada. - Produces: colunas checkout_import_runs.products_backfilled_count e checkout_import_runs.apolo_failures_count (integer, default: 0, null: false).

  • [ ] Step 1: Migration

ruby class AddProductCountsToCheckoutImportRuns < ActiveRecord::Migration[8.1] def change add_column :checkout_import_runs, :products_backfilled_count, :integer, default: 0, null: false add_column :checkout_import_runs, :apolo_failures_count, :integer, default: 0, null: false end end

  • [ ] Step 2: Migrar e anotar

bash cd modules/backend && bin/rails db:migrate && bundle exec annotaterb models

Esperado: db/schema.rb com as duas colunas e anotações atualizadas nos três arquivos de checkout_import_run.

  • [ ] Step 3: Rodar a suíte

bash cd modules/backend && bin/rails t

Esperado: PASS. A mudança é só de schema e é coberta pelos testes do use case na Task 5.

  • [ ] Step 4: Commit (perguntar antes)

bash git add modules/backend/db modules/backend/app/models/checkout_import_run.rb \ modules/backend/test/models/checkout_import_run_test.rb modules/backend/test/fixtures/checkout_import_runs.yml git commit -m "feat: conta produtos e falhas do Apolo no import"


Task 5: Use case cria e completa os ProductDebit

Files: - Modify: modules/backend/app/use_cases/debits/import_overdue_from_checkout.rb - Modify: modules/backend/app/jobs/import_overdue_checkout_payments_job.rb - Test: modules/backend/test/use_cases/debits/import_overdue_from_checkout_test.rb

Interfaces: - Consumes: Apolo.find_student, Apolo::Error (Task 1); ProductDebit.progress_from (Task 2); Checkout::Product.for_payments e as fixtures (Task 3); as colunas da Task 4. - Produces: o result do use case ganha products_backfilled e apolo_failures, e o CheckoutImportRun grava os dois contadores.

  • [ ] Step 1: Escrever os testes que falham

Em import_overdue_from_checkout_test.rb, logo depois de def reasons(...):

```ruby TRG = “formacao-de-terapeutas-trg” LEITURA = “formacao-em-leitura-corporal-e-comportamental”

def apolo_enrollment(slug, progress, active: true, created_at: "2026-09-27T10:30:58-03:00")
  { courseSlug: slug, active: active, progressPercentage: progress, completedModules: 1, totalModules: 26,
    certificateIssuedAt: nil, expiresAt: "2027-09-28T00:00:59-03:00", createdAt: created_at }
end

def stub_apolo(email, *enrollments)
  stub_request(:get, %r{/api/v2/students}).with(query: { email: email })
    .to_return(status: 200, body: { data: [ { id: 1, email: email, enrollments: enrollments } ] }.to_json)
end

def external_ids(reference) = imported(reference).product_debits.map(&:external_id).sort ```

E os testes novos, antes de test "rejects a non positive batch size":

```ruby test “creates one product debit per product with the Apolo progress” do stub_apolo(“maria.souza@example.com”, apolo_enrollment(TRG, 30.5), apolo_enrollment(LEITURA, 3.8))

  result = import

  products = imported("ref_maria").product_debits.sort_by(&:external_id)
  assert_equal [ TRG, LEITURA ].sort, products.map(&:external_id)
  trg = products.find { |product| product.external_id == TRG }
  assert_equal "Formação de Terapeutas - TRG", trg.product_name
  assert_equal 30, trg.consumer_progress
  assert_equal 1, trg.watched_lessons
  assert_equal 26, trg.total_lessons
  assert_equal Date.new(2027, 9, 28), trg.expires_on
  assert_equal 3, products.find { |product| product.external_id == LEITURA }.consumer_progress
  assert_equal 0, result[:apolo_failures]
end

test "creates a single product debit for a product sold by two checkouts" do
  import

  assert_equal [ TRG ], external_ids("ref_rafael")
end

test "uses the main checkout product when the payment has no items" do
  import

  assert_equal [ TRG ], external_ids("ref_carlos_settlement")
end

test "falls back to the product pid when the slug is blank" do
  import

  assert_equal [ TRG, "prod_sem_slug" ].sort, external_ids("ref_admin_lead")
end

test "imports the debit without products when its checkouts have none" do
  import

  assert_empty imported("ref_joana").product_debits
end

test "keeps default progress when the student has no enrollment for the product" do
  result = import

  product = imported("ref_rafael").product_debits.sole
  assert_equal 0, product.consumer_progress
  assert_not product.lifetime
  assert_equal 0, result[:apolo_failures]
end

test "picks the active and most recent enrollment of the product" do
  stub_apolo("maria.souza@example.com",
             apolo_enrollment(TRG, 90.0, active: false, created_at: "2026-09-01T10:00:00-03:00"),
             apolo_enrollment(TRG, 10.0, created_at: "2026-01-01T10:00:00-03:00"),
             apolo_enrollment(TRG, 40.0, created_at: "2026-06-01T10:00:00-03:00"))

  import

  assert_equal 40, imported("ref_maria").product_debits.find_by!(external_id: TRG).consumer_progress
end

test "imports with default progress when Apolo is unavailable" do
  stub_request(:get, %r{/api/v2/students}).with(query: { email: "maria.souza@example.com" }).to_timeout

  result = import

  assert_equal 6, result[:imported]
  assert_equal 1, result[:apolo_failures]
  assert_equal [ 0, 0 ], imported("ref_maria").product_debits.map(&:consumer_progress)
  assert_equal 1, result[:run].apolo_failures_count
end

test "asks Apolo once per email in the run" do
  import

  assert_requested :get, %r{/api/v2/students}, query: { email: "carlos.lima@example.com" }, times: 1
end

test "does not ask Apolo when the payment has no products" do
  import

  assert_not_requested :get, %r{/api/v2/students}, query: { email: "joana.checkout@example.com" }
end

test "creates the missing products of an already imported debit" do
  import
  ProductDebit.where(debit: imported("ref_maria")).delete_all
  stub_apolo("maria.souza@example.com", apolo_enrollment(TRG, 55.0))

  result = nil
  assert_no_difference -> { Debit.count } do
    result = import
  end

  assert_equal 1, result[:products_backfilled]
  assert_equal :already_imported, reasons(result, :skipped)["ref_maria"]
  assert_equal [ TRG, LEITURA ].sort, external_ids("ref_maria")
  assert_equal 55, imported("ref_maria").product_debits.find_by!(external_id: TRG).consumer_progress
  assert_equal 1, result[:run].products_backfilled_count
end

test "does not touch an imported debit that already has products" do
  import
  product = imported("ref_maria").product_debits.find_by!(external_id: TRG)
  product.update!(consumer_progress: 77)

  result = nil
  assert_no_difference -> { ProductDebit.count } do
    result = import
  end

  assert_equal 0, result[:products_backfilled]
  assert_equal 77, product.reload.consumer_progress
end

test "rolls back the products with the debit on failure" do
  ProductDebit.stub(:progress_from, ->(*) { raise "boom" }) do
    result = import

    assert_equal "RuntimeError: boom", reasons(result, :failed)["ref_maria"]
  end
  assert_nil Debit.find_by(provider_payment_id: "ref_maria")
end ```

No teste "records the run with the complete lists", depois de assert_equal 6, run.imported_count:

ruby assert_equal 0, run.products_backfilled_count assert_equal 0, run.apolo_failures_count

  • [ ] Step 2: Rodar e ver falhar

bash cd modules/backend && bin/rails t test/use_cases/debits/import_overdue_from_checkout_test.rb

Esperado: FAIL nos testes novos. Os asserts de produto recebem [], e result[:products_backfilled] e result[:apolo_failures] vêm nil.

  • [ ] Step 3: Implementar no use case

Em modules/backend/app/use_cases/debits/import_overdue_from_checkout.rb:

Comentário da classe:

ruby # Imports overdue checkout-api payments as debits, one transaction per payment, with a # ProductDebit per product of the payment and the student's progress from Apolo. # Reads the checkout database page by page (keyset on payments.id) and never writes there.

call!: trocar a linha do @summary e iniciar o cache de alunos:

ruby @summary = { imported: 0, products_backfilled: 0, apolo_failures: 0, skipped: [], failed: [] } @students = {}

import_page inteiro:

```ruby def import_page(page) customer_ids = page.map(&:customer_id).uniq customers = Checkout::Customer.where(id: customer_ids).index_by(&:id) installments = Checkout::Installment.active.where(payment_id: page.map(&:id)).group_by(&:payment_id) organization_customers = organization_customers_for(customer_ids, page.map(&:organization_id).uniq) products = Checkout::Product.for_payments(page.map(&:id)).group_by(&:payment_id) imported_debits = Debit.where(provider_payment_id: page.filter_map(&:reference)) .pluck(:payment_provider, :provider_payment_id, :id) .to_h { |provider, reference, id| [ [ provider, reference ], id ] } debits_with_products = ProductDebit.where(debit_id: imported_debits.values).distinct.pluck(:debit_id).to_set

  page.each do |payment|
    import_payment(
      payment,
      customer: customers[payment.customer_id],
      installments: installments.fetch(payment.id, []),
      organization_customer: organization_customers[[ payment.customer_id, payment.organization_id ]],
      products: products.fetch(payment.id, []),
      imported_debit_id: imported_debits[[ payment.gateway.to_s.downcase, payment.reference ]],
      debits_with_products: debits_with_products
    )
  end
end ```

import_payment inteiro:

```ruby def import_payment(payment, customer:, installments:, organization_customer:, products:, imported_debit_id:, debits_with_products:) provider = payment.gateway.to_s.downcase

  return skip(payment, :missing_reference) if payment.reference.blank?
  return skip(payment, :unsupported_provider) unless Debit.payment_provider.values.include?(provider)
  return skip_imported(payment, imported_debit_id, products, customer, debits_with_products) if imported_debit_id
  return skip(payment, :missing_customer) if customer.nil?
  return skip(payment, :missing_document) if customer.doc_number.to_s.gsub(/\D/, "").empty?

  nectar_customer = find_or_build_customer(customer)
  return skip_invalid_customer(payment, nectar_customer) if nectar_customer.changed? && nectar_customer.invalid?

  attendant = @assigner.attendant_for(customer.doc_number)
  return skip(payment, :no_active_attendant) if attendant.nil?

  student = student_for(customer.email) if products.any?

  ApplicationRecord.transaction do
    save_customer(nectar_customer, attendant)
    debit = create_debit(payment, provider, nectar_customer, attendant, organization_customer)
    installments.each { |installment| create_installment(debit, installment) }
    create_product_debits(debit, products, student)
  end
  @summary[:imported] += 1
rescue ActiveRecord::RecordNotUnique => e
  e.message.include?(IMPORT_MARKER_INDEX) ? skip(payment, :already_imported) : fail_payment(payment, e)
rescue ActiveRecord::RecordInvalid => e
  e.record.is_a?(Customer) ? skip_invalid_customer(payment, e.record) : fail_payment(payment, e)
rescue StandardError => e
  fail_payment(payment, e)
end ```

Métodos privados novos, depois de import_payment:

```ruby # An imported debit is never changed, except for getting its products when it has none. def skip_imported(payment, debit_id, products, customer, debits_with_products) backfill_products(debit_id, products, customer) unless products.empty? || debits_with_products.include?(debit_id) skip(payment, :already_imported) end

def backfill_products(debit_id, products, customer)
  student = student_for(customer&.email)
  debit = Debit.find(debit_id)
  ApplicationRecord.transaction { create_product_debits(debit, products, student) }
  @summary[:products_backfilled] += 1
end

# One Apolo call per email in the run. An Apolo failure leaves the progress at its defaults.
def student_for(email)
  key = email.to_s.strip.downcase
  return if key.empty?
  return @students[key] if @students.key?(key)

  @students[key] = Apolo.find_student(email: key)
rescue Apolo::Error => e
  Rails.logger.warn("[ImportOverdueFromCheckout] Apolo #{e.class}: #{e.message}")
  @summary[:apolo_failures] += 1
  @students[key] = nil
end

def create_product_debits(debit, products, student)
  products.each do |product|
    external_id = product.slug.presence || product.pid
    debit.product_debits.create!(
      external_id: external_id,
      product_name: product.name,
      **ProductDebit.progress_from(enrollment_for(student, external_id))
    )
  end
end

# The active enrollment wins; among equals, the most recent one.
def enrollment_for(student, course_slug)
  return if student.nil?

  student.enrollments
         .select { |enrollment| enrollment.course_slug == course_slug }
         .max_by { |enrollment| [ enrollment.active ? 1 : 0, enrollment.created_at.to_i ] }
end ```

finish inteiro:

ruby def finish(run) run.update!( finished_at: Time.current, imported_count: @summary[:imported], products_backfilled_count: @summary[:products_backfilled], apolo_failures_count: @summary[:apolo_failures], skipped: @summary[:skipped], failed: @summary[:failed] ) end

  • [ ] Step 4: Log do job

Em modules/backend/app/jobs/import_overdue_checkout_payments_job.rb, trocar o Rails.logger.info de log_summary por:

ruby Rails.logger.info( "[ImportOverdueCheckoutPaymentsJob] run=#{result[:run]&.id} imported=#{result[:imported]} " \ "products_backfilled=#{result[:products_backfilled]} apolo_failures=#{result[:apolo_failures]} " \ "skipped=#{skipped} failed=#{result[:failed].size}" )

  • [ ] Step 5: Rodar e ver passar, com a suíte inteira

bash cd modules/backend && bin/rails t test/use_cases/debits/import_overdue_from_checkout_test.rb && bin/rails t

Esperado: PASS, incluindo os testes antigos do import ("running again imports nothing new", "isolates unexpected failures...", round-robin etc.).

  • [ ] Step 6: Lint

bash cd modules/backend && bin/rubocop app/services app/models/product_debit.rb app/models/checkout \ app/use_cases/debits/import_overdue_from_checkout.rb app/jobs test

Esperado: no offenses detected.

  • [ ] Step 7: Commit (perguntar antes)

bash git add modules/backend/app/use_cases/debits/import_overdue_from_checkout.rb \ modules/backend/app/jobs/import_overdue_checkout_payments_job.rb \ modules/backend/test/use_cases/debits/import_overdue_from_checkout_test.rb git commit -m "feat: importa produtos do checkout com progresso"


Task 6: Documentação

Files: - Modify: .project/docs/rules/collections/checkout_overdue_import.md - Modify: .project/docs/architecture/backend_layers.md - Modify: .project/docs/specs/20260928171636_import_overdue_checkout_payments.md - Modify: .project/docs/specs/20260928232447_import_checkout_products.md - Modify: .project/docs/README.md

  • [ ] Step 1: Regras R-009.12 a R-009.14

Em checkout_overdue_import.md: atualizar o TLDR para citar os produtos, trocar updated: para a data do dia, citar a spec nova ao lado da antiga e acrescentar à tabela de regras:

markdown | `R-009.12` | Produtos: cada produto distinto dos checkouts do pagamento (`payment_items` + checkout principal, via `checkouts.product_id`) vira um `ProductDebit`, com `external_id` = `products.slug` (ou `products.pid` se o slug estiver vazio) e `product_name` = `products.name`. Checkout sem produto é ignorado | | `R-009.13` | Progresso: vem da matrícula do Apolo (`GET /api/v2/students?email=`) com `courseSlug` = `external_id`, preferindo a ativa e, entre elas, a mais recente. `progressPercentage` vai para `consumer_progress` (floor, 0..100), `completedModules` para `watched_lessons` (limitado ao total), `totalModules` para `total_lessons`, `certificateIssuedAt` presente para `certificate_issued` e `expiresAt` para `expires_on` (nulo = `lifetime`). Uma consulta por e-mail na execução. Sem matrícula ou com o Apolo fora do ar, o produto entra com os defaults e a falha conta em `apolo_failures_count` | | `R-009.14` | Reimport: débito já importado sem nenhum `ProductDebit` ganha os produtos (conta em `products_backfilled_count`) e continua em `skipped` como `already_imported`. Com algum produto, nada muda |

  • [ ] Step 2: Arquitetura

Em backend_layers.md, seção Services, trocar o parágrafo “Hoje services/ está vazio (só .keep)…” por:

markdown O primeiro service é o `Apolo` (`app/services/apolo.rb` e `app/services/apolo/`): `Apolo.find_student(email:)` devolve `Apolo::Student`/`Apolo::Enrollment` (`Data.define`), falha com `Apolo::Unavailable`/`Apolo::Rejected`, e o Faraday só aparece em `Apolo::Client`. As próximas integrações seguem este formato.

Trocar updated: para a data do dia e apagar app/services/.keep.

  • [ ] Step 3: Specs

  • Spec antiga (20260928171636_...): no “Fora de escopo”, trocar a linha de ProductDebit, Contract e Negotiation por Contract e Negotiation, e acrescentar “ProductDebit: ver Importação dos produtos do checkout”.
  • Spec nova (20260928232447_...): registrar os três ajustes da seção Ajustes em relação à spec deste plano, e trocar status: proposed por status: done.

  • [ ] Step 4: Índice

Em .project/docs/README.md, acrescentar uma linha à tabela de specs/:

markdown | [20260928232447_import_checkout_products.md](specs/20260928232447_import_checkout_products.md) | Cria os `ProductDebit` dos débitos importados do checkout (`external_id` = `products.slug`) com o progresso do aluno vindo do Apolo; o reimport completa os débitos sem produtos | done | high |

E uma à tabela de plans/:

markdown | [20260928233421_import_checkout_products.md](plans/20260928233421_import_checkout_products.md) | [Importação dos produtos do checkout](specs/20260928232447_import_checkout_products.md) — service `Apolo`, `ProductDebit.progress_from`, `Checkout::Product.for_payments`, contadores do run e o use case | high |

  • [ ] Step 5: Commit (perguntar antes)

bash git add .project/docs modules/backend/app/services/.keep git commit -m "docs: regras da importação de produtos do checkout"


Verificação final

  1. cd modules/backend && bin/rails t passa (a base antes do plano era de 393 testes, 0 falhas).
  2. Em development, com CHECKOUT_DATABASE_URL no banco local do checkout e apolo.api_token nas credentials:
    • bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now' loga products_backfilled e apolo_failures;
    • 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;
    • rodar de novo dá imported=0 products_backfilled=0;
    • apagar os ProductDebit de um débito e rodar de novo dá products_backfilled=1.