Importação dos produtos do checkout com o progresso do Apolo
TLDR: cada débito vindo do checkout passa a ter um
ProductDebitpor produto do pagamento, comexternal_id=products.slugdo checkout. O progresso do aluno (percentual, módulos, certificado, validade) vem do Apolo por um novo serviceApolo. 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_itempor checkout comprado (principal + order bump). O checkout principal também está empayments.checkout_id. checkouts.product_idé opcional.products.slugé único e sempre preenchido na prática: o concernSlugablegera o slug a partir donameem todobefore_validation, e a migration20260909143000preencheu 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
ProductDebitpor produto distinto do pagamento. - Gravar
products.slugemexternal_ideproducts.nameemproduct_name. - Preencher o progresso do
ProductDebitcom a matrícula do Apolo de mesmo slug. - Integrar o Apolo como service (
app/services/apolo/), no formato docommons: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
ProductDebitque já existe (sync recorrente com o Apolo). - Criar produto para checkout sem
product_id: o item é ignorado. - Usar
transactionCodepara casar a matrícula. Ele é opayments.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_idmais o depayments.checkout_id, viacheckouts.product_id → products. - Checkout sem
product_idé ignorado. Se nenhum checkout do pagamento tem produto, o débito é importado semProductDebit. - 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, usaproducts.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 aactive; entre elas, a decreatedAtmais recente. -
Mapeamento da matrícula para o
ProductDebit:ProductDebitApolo Observação consumer_progressprogressPercentagefloor, limitado a0..100.floorpara 24,9% não virar 25% e liberar negativaçãowatched_lessonscompletedModulestotal_lessonstotalModulescertificate_issuedcertificateIssuedAtpresenteexpires_onexpiresAtcomo data em BrasílialifetimeexpiresAtnulo - 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 emapolo_failuresna execução, mas não vai parafailed. completedModules > totalModulesé limitado atotalModules, para passar na validaçãowatched_lessons_within_total.
Reimport
- Hoje o reimport pula o débito já importado (
already_imported). Agora, se esse débito não tem nenhumProductDebit, os produtos dele são criados, com o progresso do Apolo, e o pagamento conta emproducts_backfilled. Ele continua emskippedcom o motivoalready_imported. - Débito já importado que já tem algum
ProductDebitnã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:)devolveApolo::Studentounilquandodatavem vazio.Apolo::Student = Data.define(:id, :enrollments)eApolo::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 deservices/apolo/.Apolo::Error,Apolo::Unavailable(timeout, conexão, 5xx) eApolo::Rejected(4xx, incluindo 401).Apolo::Clienté o único lugar com Faraday: a base URL vem deENV["APOLO_URL"](defaulthttps://apolo.ibft.app) e o token deENV["APOLO_API_TOKEN"], com timeout de abertura de 5s e de leitura de 10s.Faraday::Errornunca sai deservices/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: adicionarproducts(name,slug,pid,organization_id) epayment_items(payment_id,checkout_id), e a colunacheckouts.product_id.app/models/checkout/product.rb:Checkout::Productcom o scopefor_payments(payment_ids), que roda a consulta da seção Leitura no checkout e devolvepayment_idjunto de cada produto.app/models/checkout/payment_item.rb:Checkout::PaymentItem, usado pelos fixtures.
Apolo
Gemfile:faradaye, no grupotest,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_URLeAPOLO_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_pagecarrega os produtos da página comCheckout::Product.for_payments;- o marcador
already_importedpassa a guardar oiddo 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
ProductDebitdepois das parcelas; - no
already_importedsem produtos, cria só osProductDebit, numa transação própria; - o resumo ganha
products_backfilledeapolo_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). OProductDebitnão conhece o Apolo nemApolo::Enrollment. - Migration:
checkout_import_runs.products_backfilled_countecheckout_import_runs.apolo_failures_count(inteiros, default0), preenchidos emfinish.
Testes
test/fixtures/checkout/products.ymletest/fixtures/checkout/payment_items.yml, eproduct_idemtest/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.rbcom WebMock: aluno com matrículas,datavazio (nil), 401 →Rejected, 500 e timeout →Unavailable, e o headerAuthorization.test/models/checkout/product_test.rb:for_paymentsjuntapayment_itemse o checkout principal e ignora checkout sem produto.test/use_cases/debits/import_overdue_from_checkout_test.rb, comApolo.find_studentstubado:- 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::Unavailablecriam o produto com defaults e contam emapolo_failuressó 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,expiresAtnulo →lifetimee certificado.
Ajustes feitos na implementação
- Os erros do Apolo ficam em três arquivos (
error.rb,unavailable.rb,rejected.rb): o Zeitwerk esperaApolo::Errorsemerrors.rbe não autocarregaApolo::Unavailablede lá. payment_itemsnão ganhou model no app: a tabela só é lida pelo SQL deCheckout::Product.for_payments. O fixture usaCheckoutFixturePaymentItem, emtest/support.- A base URL do Apolo vem de
ENV["APOLO_URL"], comhttps://apolo.ibft.appquando vazia. O token vem deENV["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
bin/rails temmodules/backendpassa.- Em development, com
CHECKOUT_DATABASE_URLapontando para o banco local do checkout e comAPOLO_URLeAPOLO_API_TOKENpreenchidos:bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now';- num débito amostrado,
product_debits.pluck(:external_id)bate comproducts.slugdos checkouts do pagamento, e oconsumer_progressbate com oprogressPercentagedo Apolo para aquele slug; - rodar de novo dá
imported: 0eproducts_backfilled: 0; - apagar os
ProductDebitde um débito e rodar de novo recria só eles (products_backfilled: 1).
Documentação
.project/docs/rules/collections/checkout_overdue_import.md: novas regrasR-009.12(produtos do pagamento eexternal_id),R-009.13(progresso do Apolo e mapeamento) eR-009.14(reimport cria os produtos que faltam)..project/docs/architecture/backend_layers.md: tirar a frase “Hojeservices/está vazio” e citarApolocomo o primeiro service..project/docs/specs/20260928171636_import_overdue_checkout_payments.md: no “Fora de escopo”, apontarProductDebitpara esta spec..project/docs/README.md: registrar esta spec e o futuro plano.