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 emcheckout_import_runse o use caseDebits::ImportOverdueFromCheckoutcriando e completando osProductDebit.
Spec:
.project/docs/specs/20260928232447_import_checkout_products.mdBranch: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 tdentro demodules/backend, nunca commake. - 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 emapp/services/apolo/eapp/services/apolo.rb. Faraday::Errornunca sai deapp/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_fromsaiu doProductDebite virou o método privadoprogress_attributesdo 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_itemsnão ganha model no app: o app só lê a tabela pelo SQL deCheckout::Product.for_payments. O fixture usaCheckoutFixturePaymentItememtest/support/checkout_fixture_records.rb, o mesmo padrão já usado porcheckoutseorganizations.Apolo::Clientusahttps://apolo.ibft.appquandoENV["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 deProductDebit,ContracteNegotiationporContracteNegotiation, 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 trocarstatus: proposedporstatus: 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
cd modules/backend && bin/rails tpassa (a base antes do plano era de 393 testes, 0 falhas).- Em development, com
CHECKOUT_DATABASE_URLno banco local do checkout eapolo.api_tokennas credentials:bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now'logaproducts_backfilledeapolo_failures;- num débito amostrado,
product_debits.pluck(:external_id)bate comproducts.slugdos checkouts do pagamento, e oconsumer_progressbate com oprogressPercentagedo Apolo; - rodar de novo dá
imported=0 products_backfilled=0; - apagar os
ProductDebitde um débito e rodar de novo dáproducts_backfilled=1.