Importação dos pagamentos atrasados do checkout — Plano de implementação
TLDR: conexão somente leitura com o banco do checkout, colunas
provider_*e registrocheckout_import_runsno domínio,AttendantAssigner(CSV + round-robin), o use caseDebits::ImportOverdueFromCheckoutpaginado por keyset e o jobImportOverdueCheckoutPaymentsJob.
Spec:
.project/docs/specs/20260928171636_import_overdue_checkout_payments.mdBranch:feat/import-overdue-checkout-payments
Arquitetura: o CheckoutRecord (abstract, connects_to :checkout, readonly?) sustenta quatro
models de leitura em app/models/checkout/. O use case pagina Checkout::Payment.overdue_since
com find_in_batches. Para cada página, carrega clientes, parcelas e organization_customers em 3
consultas, e grava cada pagamento no nectar numa transação própria. O Debits::AttendantAssigner
resolve o atendente pelo CSV e usa o round-robin quando o CSV não resolve.
Stack: Rails 8.1 multi-db, PostgreSQL, u-case (Micro::Case), enumerize, Solid Queue,
Minitest com fixtures.
Restrições globais
- Testes com
bin/rails t, rodado emmodules/backend, nunca commake. - Testes usam fixtures YAML, sem factory_bot. Registros avulsos com
create!só quando a fixture não cobre o caso. - Código, nomes e testes em inglês. Docs em
.project/docs/em português. - Commits em uma linha, com no máximo 60 caracteres e sem menção a IA. Sempre perguntar antes de commitar.
- Não gravar nada no banco do checkout:
readonly?sempretrue, e usuário com sóSELECTem produção. - Não alterar
RegistryNegativation.gateway_ref. - Todos os caminhos abaixo são relativos a
modules/backend/, exceto os de.project/docs/. Os comandos partem da raiz do repo.
Dependências entre tasks
Task 1 (conexão + models checkout) ─┐
Task 2 (domínio: colunas provider_*) ├─► Task 4 (use case) ─► Task 5 (job) ─► Task 6 (docs + CSV)
Task 3 (AttendantAssigner) ─┘
As tasks 1, 2 e 3 são independentes e podem rodar em paralelo.
Task 1: Conexão somente leitura com o checkout e models de leitura
Files:
- Modify: config/database.yml
- Create: db/checkout_schema.rb
- Create: db/checkout_migrate/.keep
- Create: app/models/checkout_record.rb
- Create: app/models/checkout/payment.rb
- Create: app/models/checkout/installment.rb
- Create: app/models/checkout/customer.rb
- Create: app/models/checkout/organization_customer.rb
- Create: test/support/checkout_fixture_records.rb
- Modify: test/test_helper.rb
- Create: test/fixtures/checkout/organizations.yml
- Create: test/fixtures/checkout/checkouts.yml
- Create: test/fixtures/checkout/customers.yml
- Create: test/fixtures/checkout/organization_customers.yml
- Create: test/fixtures/checkout/payments.yml
- Create: test/fixtures/checkout/installments.yml
- Test: test/models/checkout/payment_test.rb
- Modify: ../../.env.example
Interfaces:
- Consumes: nada.
- Produces:
- CheckoutRecord: abstract, conexão :checkout, readonly? sempre true.
- Checkout::Payment.overdue_since(date): relation com os atributos extras organization_id e organization_slug.
- Checkout::Installment.active: parcelas com gateway_deleted falso ou nulo.
- Checkout::Customer e Checkout::OrganizationCustomer.
- Fixtures checkout_payments(:label), checkout_customers(:label) etc.
Decisão de teste: as tabelas checkouts e organizations não têm model em app/. Para as
fixtures delas funcionarem, test/support/checkout_fixture_records.rb declara duas classes só de
teste (CheckoutFixtureCheckout e CheckoutFixtureOrganization), referenciadas em
_fixture.model_class.
- [ ] Step 1: Configurar a conexão e o schema de teste
config/database.yml (substitui o arquivo inteiro):
```yaml # PostgreSQL. Versions 9.5 and up are supported. # default: &default adapter: postgresql encoding: unicode pool: <%= ENV.fetch(“DATABASE_POOL”, 5) %> host: <%= ENV.fetch(“DATABASE_HOST”, “localhost”) %> port: <%= ENV.fetch(“DATABASE_PORT”, 5432) %> username: <%= ENV.fetch(“DATABASE_USERNAME”, “postgres”) %> password: <%= ENV.fetch(“DATABASE_PASSWORD”, “postgres”) %>
Read-only connection to the checkout-api database (source of the overdue payments import).
# Outside test it never runs database tasks: nectar does not own this schema. checkout: &checkout adapter: postgresql encoding: unicode pool: <%= ENV.fetch(“DATABASE_POOL”, 5) %> url: <%= ENV[“CHECKOUT_DATABASE_URL”] %> database_tasks: false
development: primary: «: *default database: <%= ENV.fetch(“DATABASE_NAME”, “cobranca_api_development”) %> checkout: «: *checkout
Warning: The database defined as “test” will be erased and
# re-generated from your development database when you run “rake”. # Do not set this db to the same as development or production. test: primary: «: *default database: cobranca_api_test<%= ENV[“TEST_ENV_NUMBER”] %> checkout: «: *default database: cobranca_api_checkout_test<%= ENV[“TEST_ENV_NUMBER”] %> migrations_paths: db/checkout_migrate schema_dump: checkout_schema.rb
staging: primary: «: *default url: <%= ENV[“DATABASE_URL”] %> checkout: «: *checkout
production: primary: «: *default url: <%= ENV[“DATABASE_URL”] %> checkout: «: *checkout ```
db/checkout_migrate/.keep: arquivo vazio. Ele existe para o banco checkout de teste não herdar
as migrations de db/migrate.
db/checkout_schema.rb, um espelho mínimo do checkout-api (checkout-api/db/schema.rb) só com o que
o import lê:
```ruby # Minimal mirror of the checkout-api schema, used only by the test database. # Keep it in sync with checkout-api/db/schema.rb for the columns the import reads. ActiveRecord::Schema[8.1].define(version: 1) do enable_extension “plpgsql”
create_table “organizations”, force: :cascade do |t| t.string “name” t.string “slug” t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“slug”], unique: true end
create_table “checkouts”, force: :cascade do |t| t.string “name” t.string “slug” t.bigint “organization_id”, default: 1, null: false t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“organization_id”] end
create_table “customers”, force: :cascade do |t| t.string “name” t.string “doc_number” t.string “email” t.string “phone_number” t.string “gateway_id” t.datetime “created_at”, null: false t.datetime “updated_at”, null: false end
create_table “organization_customers”, force: :cascade do |t| t.bigint “customer_id”, null: false t.bigint “organization_id”, null: false t.string “gateway_customer_id” t.datetime “created_at”, null: false t.datetime “updated_at”, null: false end
create_table “payments”, force: :cascade do |t| t.bigint “customer_id”, null: false t.bigint “checkout_id”, null: false t.integer “installment_count” t.string “gateway_installment_id” t.decimal “total”, default: “0.0” t.string “gateway”, default: “ASAAS” t.string “gateway_id” t.string “gateway_checkout_url” t.string “reference” t.string “status”, default: “draft” t.string “kind”, default: “standard” t.boolean “gateway_deleted”, default: false t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“reference”], unique: true t.index [“status”] end
create_table “installments”, force: :cascade do |t| t.bigint “payment_id”, null: false t.string “gateway_id”, null: false t.string “status” t.integer “installment_number” t.decimal “total”, default: “0.0” t.decimal “interest_total”, default: “0.0” t.decimal “discount_total”, default: “0.0” t.string “billing_type” t.date “due_date” t.date “paid_date” t.string “payment_link” t.boolean “gateway_deleted” t.datetime “created_at”, null: false t.datetime “updated_at”, null: false t.index [“payment_id”] end end ```
../../.env.example, no bloco de banco, depois de DATABASE_POOL=5:
# Read-only connection to the checkout-api database (overdue payments import)
CHECKOUT_DATABASE_URL=
Criar os bancos de teste:
bash
cd modules/backend && RAILS_ENV=test bin/rails db:create db:test:prepare
Expected: cobranca_api_test e cobranca_api_checkout_test criados, com schemas carregados.
- [ ] Step 2: Escrever as fixtures e o teste que falha
test/support/checkout_fixture_records.rb:
```ruby
# Test-only records for checkout tables the app never reads through a model.
# They exist so fixtures can populate checkouts and organizations.
class CheckoutFixtureOrganization < CheckoutRecord
self.table_name = “organizations”
end
class CheckoutFixtureCheckout < CheckoutRecord self.table_name = “checkouts” end ```
test/test_helper.rb: depois de require "minitest/mock", adicionar:
ruby
Dir[File.expand_path("support/**/*.rb", __dir__)].each { |file| require file }
test/fixtures/checkout/organizations.yml:
```yaml _fixture: model_class: CheckoutFixtureOrganization
ibft: name: IBFT slug: ibft
outra: name: Outra Escola slug: outra-escola ```
test/fixtures/checkout/checkouts.yml:
```yaml _fixture: model_class: CheckoutFixtureCheckout
ibft_course: name: Curso IBFT slug: curso-ibft organization_id: <%= ActiveRecord::FixtureSet.identify(:ibft) %>
outra_course: name: Curso Outra slug: curso-outra organization_id: <%= ActiveRecord::FixtureSet.identify(:outra) %> ```
test/fixtures/checkout/customers.yml. O joana e o rafael têm os mesmos CPFs das fixtures
customers(:joana) e customers(:rafael) do nectar:
```yaml _fixture: model_class: Checkout::Customer
maria: name: Maria Souza doc_number: “11144477735” email: maria.souza@example.com phone_number: “11999990001” gateway_id: cus_global_maria
joana: name: Joana R. (checkout) doc_number: “39053344705” email: joana.checkout@example.com phone_number: “11999990002” gateway_id: cus_global_joana
rafael: name: Rafael Duarte doc_number: “52998224725” email: rafael.duarte@example.com phone_number: “21977776666” gateway_id: cus_global_rafael
carlos: name: Carlos Lima doc_number: “86288366757” email: carlos.lima@example.com phone_number: “11999990004” gateway_id: cus_global_carlos
admin_lead: name: Lead do Admin doc_number: “22233344405” email: admin.lead@example.com phone_number: “11999990005” gateway_id: cus_global_admin_lead
no_email: name: Sem Email doc_number: “55566677788” phone_number: “11999990006” gateway_id: cus_global_no_email ```
test/fixtures/checkout/organization_customers.yml. O maria_ibft tem id explícito menor que o
maria_ibft_duplicate, para testar a regra “vale o menor id”:
```yaml _fixture: model_class: Checkout::OrganizationCustomer
maria_ibft: id: 1 customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> organization_id: <%= ActiveRecord::FixtureSet.identify(:ibft) %> gateway_customer_id: cus_ibft_maria
maria_ibft_duplicate: id: 2 customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> organization_id: <%= ActiveRecord::FixtureSet.identify(:ibft) %> gateway_customer_id: cus_ibft_maria_duplicate
joana_ibft: id: 3 customer_id: <%= ActiveRecord::FixtureSet.identify(:joana) %> organization_id: <%= ActiveRecord::FixtureSet.identify(:ibft) %> gateway_customer_id: cus_ibft_joana
joana_outra: id: 4 customer_id: <%= ActiveRecord::FixtureSet.identify(:joana) %> organization_id: <%= ActiveRecord::FixtureSet.identify(:outra) %> gateway_customer_id: cus_outra_joana ```
test/fixtures/checkout/payments.yml. created_at fica em UTC: 01/01/2026 00:00 em Brasília é
2026-01-01 03:00:00 UTC.
```yaml _fixture: model_class: Checkout::Payment
<% ibft = ActiveRecord::FixtureSet.identify(:ibft_course) %> <% outra = ActiveRecord::FixtureSet.identify(:outra_course) %>
overdue_standard: customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_maria gateway_id: pay_maria_1 gateway_installment_id: inst_maria gateway_checkout_url: https://pay.ibft.com.br/payments/pay_maria_1 installment_count: 3 total: 300.00 created_at: “2026-02-10 12:00:00” updated_at: “2026-02-10 12:00:00”
overdue_repayment: customer_id: <%= ActiveRecord::FixtureSet.identify(:joana) %> checkout_id: <%= outra %> status: overdue kind: repayment gateway: ASAAS reference: ref_joana gateway_id: pay_joana_1 gateway_installment_id: inst_joana gateway_checkout_url: https://pay.outra.com.br/payments/pay_joana_1 installment_count: 1 total: 150.50 created_at: “2026-03-01 12:00:00” updated_at: “2026-03-01 12:00:00”
overdue_settlement: customer_id: <%= ActiveRecord::FixtureSet.identify(:carlos) %> checkout_id: <%= ibft %> status: overdue kind: settlement gateway: ASAAS reference: ref_carlos_settlement gateway_id: pay_carlos_1 total: 90.00 created_at: “2026-04-01 12:00:00” updated_at: “2026-04-01 12:00:00”
overdue_renewal: customer_id: <%= ActiveRecord::FixtureSet.identify(:admin_lead) %> checkout_id: <%= ibft %> status: overdue kind: renewal gateway: ASAAS reference: ref_admin_lead gateway_id: pay_admin_lead_1 total: 120.00 created_at: “2026-05-01 12:00:00” updated_at: “2026-05-01 12:00:00”
overdue_rafael: customer_id: <%= ActiveRecord::FixtureSet.identify(:rafael) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_rafael gateway_id: pay_rafael_1 total: 80.00 created_at: “2026-05-02 12:00:00” updated_at: “2026-05-02 12:00:00”
overdue_boundary_after: customer_id: <%= ActiveRecord::FixtureSet.identify(:carlos) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_carlos_boundary gateway_id: pay_carlos_2 total: 60.00 created_at: “2026-01-01 03:05:00” updated_at: “2026-01-01 03:05:00”
overdue_no_email: customer_id: <%= ActiveRecord::FixtureSet.identify(:no_email) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_no_email gateway_id: pay_no_email_1 total: 50.00 created_at: “2026-06-01 12:00:00” updated_at: “2026-06-01 12:00:00”
overdue_no_reference: customer_id: <%= ActiveRecord::FixtureSet.identify(:carlos) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS gateway_id: pay_carlos_3 total: 40.00 created_at: “2026-06-02 12:00:00” updated_at: “2026-06-02 12:00:00”
overdue_unsupported_provider: customer_id: <%= ActiveRecord::FixtureSet.identify(:carlos) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: PAGARME reference: ref_carlos_pagarme gateway_id: pg_carlos_1 total: 30.00 created_at: “2026-06-03 12:00:00” updated_at: “2026-06-03 12:00:00”
overdue_boundary_before: customer_id: <%= ActiveRecord::FixtureSet.identify(:carlos) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_carlos_before gateway_id: pay_carlos_4 total: 20.00 created_at: “2026-01-01 01:00:00” updated_at: “2026-01-01 01:00:00”
overdue_2025: customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_maria_2025 gateway_id: pay_maria_2025 total: 10.00 created_at: “2025-06-01 12:00:00” updated_at: “2025-06-01 12:00:00”
overdue_deleted: customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> checkout_id: <%= ibft %> status: overdue kind: standard gateway: ASAAS reference: ref_maria_deleted gateway_id: pay_maria_deleted gateway_deleted: true total: 10.00 created_at: “2026-02-11 12:00:00” updated_at: “2026-02-11 12:00:00”
paid: customer_id: <%= ActiveRecord::FixtureSet.identify(:maria) %> checkout_id: <%= ibft %> status: paid kind: standard gateway: ASAAS reference: ref_maria_paid gateway_id: pay_maria_paid total: 10.00 created_at: “2026-02-12 12:00:00” updated_at: “2026-02-12 12:00:00” ```
test/fixtures/checkout/installments.yml:
```yaml _fixture: model_class: Checkout::Installment
<% maria = ActiveRecord::FixtureSet.identify(:overdue_standard) %>
maria_1_paid: payment_id: <%= maria %> gateway_id: pay_maria_1 installment_number: 1 status: paid total: 100.00 interest_total: 0 discount_total: 5.00 billing_type: PIX due_date: “2026-02-15” paid_date: “2026-02-14” payment_link: https://asaas.com/i/pay_maria_1
maria_2_overdue: payment_id: <%= maria %> gateway_id: pay_maria_2 installment_number: 2 status: overdue total: 100.00 interest_total: 2.35 discount_total: 0 billing_type: BOLETO due_date: “2026-03-15” payment_link: https://asaas.com/i/pay_maria_2
maria_3_pending: payment_id: <%= maria %> gateway_id: pay_maria_3 installment_number: 3 status: pending total: 100.00 billing_type: CREDIT_CARD due_date: “2026-04-15” payment_link: https://asaas.com/i/pay_maria_3
maria_refunded: payment_id: <%= maria %> gateway_id: pay_maria_refunded installment_number: 4 status: refunded total: 100.00 due_date: “2026-05-15”
maria_deleted: payment_id: <%= maria %> gateway_id: pay_maria_deleted_installment installment_number: 5 status: overdue total: 100.00 due_date: “2026-06-15” gateway_deleted: true
joana_1_overdue: payment_id: <%= ActiveRecord::FixtureSet.identify(:overdue_repayment) %> gateway_id: pay_joana_1 installment_number: 1 status: overdue total: 150.50 billing_type: BOLETO due_date: “2026-03-10” ```
test/models/checkout/payment_test.rb:
```ruby require “test_helper”
module Checkout class PaymentTest < ActiveSupport::TestCase SINCE = Date.new(2026, 1, 1)
test "overdue_since returns only overdue, not deleted payments created since the date" do
references = Checkout::Payment.overdue_since(SINCE).map(&:reference)
assert_includes references, "ref_maria"
assert_includes references, "ref_joana"
assert_not_includes references, "ref_maria_paid"
assert_not_includes references, "ref_maria_2025"
assert_not_includes references, "ref_maria_deleted"
end
test "overdue_since cuts at midnight in Brasilia, not UTC" do
references = Checkout::Payment.overdue_since(SINCE).map(&:reference)
assert_includes references, "ref_carlos_boundary"
assert_not_includes references, "ref_carlos_before"
end
test "overdue_since exposes the organization slug through checkouts" do
payment = Checkout::Payment.overdue_since(SINCE).find { |p| p.reference == "ref_joana" }
assert_equal "outra-escola", payment.organization_slug
assert_equal ActiveRecord::FixtureSet.identify(:outra), payment.organization_id
end
test "overdue_since paginates by id with find_in_batches" do
pages = Checkout::Payment.overdue_since(SINCE).find_in_batches(batch_size: 2).to_a
ids = pages.flatten.map(&:id)
assert_operator pages.size, :>, 1
assert_equal ids.sort, ids
assert_equal Checkout::Payment.overdue_since(SINCE).to_a.size, ids.size
end
test "records are read-only" do
assert_raises(ActiveRecord::ReadOnlyRecord) do
checkout_payments(:overdue_standard).update!(status: "paid")
end
end
test "installments active scope ignores gateway deleted ones" do
numbers = Checkout::Installment.active
.where(payment_id: checkout_payments(:overdue_standard).id)
.pluck(:installment_number)
assert_equal [1, 2, 3, 4], numbers.sort
end end end ```
- [ ] Step 3: Rodar e confirmar que falha
bash
cd modules/backend && bin/rails t test/models/checkout/payment_test.rb
Expected: FAIL — NameError: uninitialized constant CheckoutRecord, levantado ao carregar
test/support/checkout_fixture_records.rb.
- [ ] Step 4: Implementar os models
app/models/checkout_record.rb:
```ruby # Read-only base for the checkout-api database. nectar never writes there. class CheckoutRecord < ActiveRecord::Base self.abstract_class = true
connects_to database: { writing: :checkout, reading: :checkout }
def readonly? = true end ```
app/models/checkout/payment.rb:
```ruby module Checkout class Payment < CheckoutRecord self.table_name = “payments”
# Batching (find_in_batches) owns the order, so this scope declares none.
scope :overdue_since, ->(date) {
joins("INNER JOIN checkouts ON checkouts.id = payments.checkout_id")
.joins("INNER JOIN organizations ON organizations.id = checkouts.organization_id")
.where(status: "overdue", created_at: date.in_time_zone.beginning_of_day..)
.where(gateway_deleted: [false, nil])
.select("payments.*, organizations.id AS organization_id, organizations.slug AS organization_slug")
} end end ```
app/models/checkout/installment.rb:
```ruby module Checkout class Installment < CheckoutRecord self.table_name = “installments”
scope :active, -> { where(gateway_deleted: [false, nil]) } end end ```
app/models/checkout/customer.rb:
ruby
module Checkout
class Customer < CheckoutRecord
self.table_name = "customers"
end
end
app/models/checkout/organization_customer.rb:
ruby
module Checkout
class OrganizationCustomer < CheckoutRecord
self.table_name = "organization_customers"
end
end
- [ ] Step 5: Rodar e confirmar que passa, junto com a suíte inteira
bash
cd modules/backend && bin/rails t test/models/checkout/payment_test.rb && bin/rails t
Expected: PASS. A suíte inteira continua verde (baseline: 342 runs), mais os 6 testes novos.
- [ ] Step 6: Commit
bash
cd modules/backend && git add config/database.yml db/checkout_schema.rb db/checkout_migrate/.keep app/models/checkout_record.rb app/models/checkout test/support test/test_helper.rb test/fixtures/checkout test/models/checkout ../../.env.example
git commit -m "feat: add read-only checkout database connection"
Task 2: Colunas provider_* e checkout_import_runs no domínio
Files:
- Create: db/migrate/20260928190000_add_provider_fields_to_debits.rb
- Create: db/migrate/20260928190100_add_payment_link_and_billing_type_to_installments.rb
- Create: db/migrate/20260928190200_rename_gateway_refs_to_provider_ids.rb
- Create: db/migrate/20260928190300_create_checkout_import_runs.rb
- Create: app/models/checkout_import_run.rb
- Create: test/fixtures/checkout_import_runs.yml
- Test: test/models/checkout_import_run_test.rb
- Modify: db/schema.rb (gerado)
- Modify: app/models/debit.rb
- Modify: app/models/installment.rb
- Modify: app/models/customer.rb
- Modify: test/fixtures/installments.yml
- Modify: test/fixtures/customers.yml
- Modify: anotações de schema em app/models/{debit,installment,customer}.rb, app/serializers/debit_serializer.rb, test/models/{debit,installment,customer}_test.rb e test/fixtures/{debits,installments,customers}.yml
- Test: test/models/debit_test.rb, test/models/installment_test.rb, test/models/customer_test.rb
Interfaces:
- Consumes: nada.
- Produces:
- Debit ganha payment_provider (enum asaas|hotmart, aceita nulo), provider_payment_id, provider_charge_id, provider_installment_id, provider_checkout_url, provider_customer_id e organization_slug.
- Debit::CHECKOUT_KIND_MAP (String → Symbol) e renewal em payment_type.
- Installment ganha payment_link, billing_type (enum boleto|pix|credit_card) e provider_charge_id, que era gateway_ref.
- Installment::CHECKOUT_STATUS_MAP e Installment::CHECKOUT_BILLING_TYPE_MAP.
- Customer#provider_customer_id, que era gateway_customer_ref.
- CheckoutImportRun (since, started_at, finished_at, imported_count, skipped jsonb, failed jsonb) e #skipped_by_reason → Hash.
- [ ] Step 1: Escrever os testes que falham
Adicionar ao fim da classe em test/models/debit_test.rb:
```ruby test “accepts renewal as payment_type” do debit = debits(:joana_pending) debit.payment_type = :renewal
assert_predicate debit, :valid? end
test “maps checkout payment kinds to payment types” do assert_equal( { “standard” => :installment, “repayment” => :repayment_first, “settlement” => :full_settlement, “renewal” => :renewal }, Debit::CHECKOUT_KIND_MAP ) end
test “payment_provider accepts asaas and hotmart and allows nil” do debit = debits(:joana_pending)
assert_nil debit.payment_provider
debit.payment_provider = :hotmart
assert_predicate debit, :valid?
debit.payment_provider = :stripe
assert_not debit.valid? end
test “provider_payment_id is unique per payment_provider” do debits(:joana_pending).update!(payment_provider: :asaas, provider_payment_id: “ref_1”) other = debits(:rafael_negotiated)
other.assign_attributes(payment_provider: :asaas, provider_payment_id: "ref_1")
assert_not other.valid?
assert_includes other.errors[:provider_payment_id], "has already been taken"
other.payment_provider = :hotmart
assert_predicate other, :valid? end ```
Adicionar ao fim da classe em test/models/installment_test.rb:
```ruby test “billing_type accepts boleto, pix and credit_card and allows nil” do installment = installments(:joana_overdue)
assert_nil installment.billing_type
installment.billing_type = :pix
assert_predicate installment, :valid?
installment.billing_type = :cash
assert_not installment.valid? end
test “provider_charge_id is unique” do installment = installments(:joana_overdue) installment.provider_charge_id = installments(:rafael_paid).provider_charge_id
assert_not installment.valid?
assert_includes installment.errors[:provider_charge_id], "has already been taken" end
test “maps checkout installment status and billing type” do assert_equal({ “paid” => :paid, “pending” => :upcoming, “overdue” => :overdue }, Installment::CHECKOUT_STATUS_MAP) assert_equal({ “BOLETO” => :boleto, “PIX” => :pix, “CREDIT_CARD” => :credit_card }, Installment::CHECKOUT_BILLING_TYPE_MAP) end ```
Adicionar ao fim da classe em test/models/customer_test.rb:
```ruby test “provider_customer_id is unique” do customer = customers(:rafael) customer.provider_customer_id = customers(:joana).provider_customer_id
assert_not customer.valid?
assert_includes customer.errors[:provider_customer_id], "has already been taken" end ```
test/fixtures/checkout_import_runs.yml:
yaml
finished:
since: "2026-01-01"
started_at: "2026-09-28 18:00:00"
finished_at: "2026-09-28 18:05:00"
imported_count: 2
skipped: '[{"provider_payment_id": "ref_a", "reason": "already_imported"}, {"provider_payment_id": "ref_b", "reason": "already_imported"}, {"provider_payment_id": null, "reason": "missing_reference"}]'
failed: '[]'
test/models/checkout_import_run_test.rb:
```ruby require “test_helper”
class CheckoutImportRunTest < ActiveSupport::TestCase test “requires since and started_at” do run = CheckoutImportRun.new
assert_not run.valid?
assert_includes run.errors[:since], "can't be blank"
assert_includes run.errors[:started_at], "can't be blank" end
test “defaults to empty lists and zero imported” do run = CheckoutImportRun.create!(since: Date.new(2026, 1, 1), started_at: Time.current)
assert_equal 0, run.imported_count
assert_equal [], run.skipped
assert_equal [], run.failed
assert_nil run.finished_at end
test “counts skipped entries by reason” do assert_equal({ “already_imported” => 2, “missing_reference” => 1 }, checkout_import_runs(:finished).skipped_by_reason) end end ```
Renomear nas fixtures:
- test/fixtures/installments.yml, linha 56: de gateway_ref: pay_000000000001 para provider_charge_id: pay_000000000001
- test/fixtures/customers.yml, linha 41: de gateway_customer_ref: cus_000000000001 para provider_customer_id: cus_000000000001
- [ ] Step 2: Rodar e confirmar que falha
bash
cd modules/backend && bin/rails t test/models/debit_test.rb test/models/installment_test.rb test/models/customer_test.rb
Expected: FAIL — ActiveRecord::Fixture::FixtureError: table "installments" has no columns named "provider_charge_id".
- [ ] Step 3: Escrever as migrations e alterar os models
db/migrate/20260928190000_add_provider_fields_to_debits.rb:
```ruby class AddProviderFieldsToDebits < ActiveRecord::Migration[8.1] def change change_table :debits, bulk: true do |t| t.string :payment_provider t.string :provider_payment_id t.string :provider_charge_id t.string :provider_installment_id t.string :provider_checkout_url t.string :provider_customer_id t.string :organization_slug end
add_index :debits, %i[payment_provider provider_payment_id],
unique: true, where: "provider_payment_id IS NOT NULL" end end ```
db/migrate/20260928190100_add_payment_link_and_billing_type_to_installments.rb:
ruby
class AddPaymentLinkAndBillingTypeToInstallments < ActiveRecord::Migration[8.1]
def change
change_table :installments, bulk: true do |t|
t.string :payment_link
t.string :billing_type
end
end
end
db/migrate/20260928190200_rename_gateway_refs_to_provider_ids.rb:
ruby
class RenameGatewayRefsToProviderIds < ActiveRecord::Migration[8.1]
def change
rename_column :installments, :gateway_ref, :provider_charge_id
rename_column :customers, :gateway_customer_ref, :provider_customer_id
end
end
db/migrate/20260928190300_create_checkout_import_runs.rb:
```ruby class CreateCheckoutImportRuns < ActiveRecord::Migration[8.1] def change create_table :checkout_import_runs do |t| t.date :since, null: false t.datetime :started_at, null: false t.datetime :finished_at t.integer :imported_count, null: false, default: 0 t.jsonb :skipped, null: false, default: [] t.jsonb :failed, null: false, default: []
t.timestamps
end end end ```
app/models/checkout_import_run.rb:
```ruby
# One row per ImportOverdueCheckoutPaymentsJob execution. finished_at nil means it did not finish.
class CheckoutImportRun < ApplicationRecord
validates :since, :started_at, presence: true
def skipped_by_reason = skipped.group_by { |entry| entry[“reason”] }.transform_values(&:size) end ```
bash
cd modules/backend && bin/rails db:migrate && git diff db/schema.rb
Expected: db/schema.rb com as colunas novas. Os índices index_installments_on_provider_charge_id
e index_customers_on_provider_customer_id têm where com o nome novo da coluna. Se o where ainda
mostrar o nome antigo, trocar o rename_column por remove_index + rename_column + add_index
com where: "<coluna nova> IS NOT NULL".
app/models/debit.rb: substituir o bloco enumerize :payment_type e adicionar logo abaixo:
```ruby CHECKOUT_KIND_MAP = { “standard” => :installment, “repayment” => :repayment_first, “settlement” => :full_settlement, “renewal” => :renewal }.freeze
enumerize :payment_type, in: %i[installment repayment_first repayment_second full_settlement partial_settlement renewal], default: :installment, predicates: true, scope: true
enumerize :payment_provider, in: %i[asaas hotmart], scope: true ```
E, depois de validates :opened_at, presence: true:
ruby
validates :provider_payment_id, uniqueness: { scope: :payment_provider }, allow_nil: true
app/models/installment.rb: depois de UNPAID_STATUSES:
ruby
CHECKOUT_STATUS_MAP = { "paid" => :paid, "pending" => :upcoming, "overdue" => :overdue }.freeze
CHECKOUT_BILLING_TYPE_MAP = { "BOLETO" => :boleto, "PIX" => :pix, "CREDIT_CARD" => :credit_card }.freeze
Depois do enumerize :status:
ruby
enumerize :billing_type, in: %i[boleto pix credit_card]
E trocar validates :gateway_ref, uniqueness: true, allow_nil: true por:
ruby
validates :provider_charge_id, uniqueness: true, allow_nil: true
app/models/customer.rb: trocar validates :gateway_customer_ref, uniqueness: true, allow_nil: true
por:
ruby
validates :provider_customer_id, uniqueness: true, allow_nil: true
Anotações de schema, atualizadas à mão porque o projeto não usa gem de annotate:
bash
cd modules/backend && grep -rl "gateway_ref\|gateway_customer_ref" app/models/installment.rb app/models/customer.rb test/models/installment_test.rb test/models/customer_test.rb test/fixtures/installments.yml test/fixtures/customers.yml \
| xargs sed -i '' -e 's/gateway_customer_ref/provider_customer_id/g' -e 's/gateway_ref /provider_charge_id /g' -e 's/index_installments_on_gateway_ref (gateway_ref)/index_installments_on_provider_charge_id (provider_charge_id)/g' -e 's/gateway_ref IS NOT NULL/provider_charge_id IS NOT NULL/g'
Depois disso, acrescentar à mão as colunas e índices novos no bloco # == Schema Information de:
- app/models/debit.rb, app/serializers/debit_serializer.rb, test/models/debit_test.rb e test/fixtures/debits.yml: organization_slug, payment_provider, provider_charge_id, provider_checkout_url, provider_customer_id, provider_installment_id, provider_payment_id e o índice index_debits_on_payment_provider_and_provider_payment_id;
- app/models/installment.rb, test/models/installment_test.rb e test/fixtures/installments.yml: billing_type e payment_link.
Em todos os casos, copiar tipos e ordem do db/schema.rb, em ordem alfabética como nas anotações
atuais. Conferir que não sobrou nenhum uso das colunas antigas:
bash
cd modules/backend && grep -rn "gateway_ref\|gateway_customer_ref" app test | grep -v registry_negativation
Expected: nenhuma saída.
- [ ] Step 4: Rodar e confirmar que passa
bash
cd modules/backend && bin/rails t
Expected: PASS, com a suíte inteira verde.
- [ ] Step 5: Commit
bash
cd modules/backend && git add db/migrate db/schema.rb app/models app/serializers/debit_serializer.rb test/models test/fixtures/debits.yml test/fixtures/installments.yml test/fixtures/customers.yml test/fixtures/checkout_import_runs.yml
git commit -m "feat: add provider fields and checkout import runs"
Task 3: Debits::AttendantAssigner
Files:
- Create: app/use_cases/debits/attendant_assigner.rb
- Create: test/fixtures/files/initial_import.csv
- Test: test/use_cases/debits/attendant_assigner_test.rb
Interfaces:
- Consumes: User.active_attendants, que já existe.
- Produces: Debits::AttendantAssigner.new(csv_path:, attendants:) e #attendant_for(document) → User | nil.
- [ ] Step 1: Escrever o CSV de teste e o teste que falha
test/fixtures/files/initial_import.csv. Ele tem o mesmo header do arquivo real, um campo com
vírgula entre aspas, um CPF repetido, um nome com acento e caixa diferentes, e um admin:
csv
Lead,Nome,CPF,E-mail,Celular,Usuário responsável
Maria Souza,Maria Souza,11144477735,maria.souza@example.com,+5511999990001,Aretha Morais
"Lead, com vírgula",Lead do Admin,22233344405,admin.lead@example.com,+5511999990005,Francisco Miguel
Maria Souza,Maria Souza,11144477735,maria.souza@example.com,+5511999990001,Pedro Henrique
Zeca Acento,Zeca Acento,03344455566,zeca@example.com,+5511999990007,ARETHA MORÁIS
test/use_cases/debits/attendant_assigner_test.rb:
```ruby require “test_helper”
module Debits class AttendantAssignerTest < ActiveSupport::TestCase def build(attendants: User.active_attendants.order(:id)) Debits::AttendantAssigner.new(csv_path: file_fixture(“initial_import.csv”), attendants: attendants) end
test "returns the attendant named in the CSV for the document" do
assert_equal users(:attendant), build.attendant_for("11144477735")
end
test "normalizes the document before looking it up" do
assert_equal users(:attendant), build.attendant_for("111.444.777-35")
end
test "keeps the first CSV row when a document repeats" do
pedro = users(:inactive_attendant)
pedro.update!(status: :active)
assert_equal users(:attendant), build.attendant_for("11144477735")
end
test "matches names ignoring case and accents" do
assert_equal users(:attendant), build.attendant_for("03344455566")
end
test "falls back to round-robin when the CSV names a non attendant" do
second = User.create!(name: "Segunda Atendente", email: "segunda@ibft.com.br", password: "cob123")
assigner = build
assert_equal users(:attendant), assigner.attendant_for("22233344405")
assert_equal second, assigner.attendant_for("22233344405")
end
test "rotates through active attendants for documents outside the CSV" do
second = User.create!(name: "Segunda Atendente", email: "segunda@ibft.com.br", password: "cob123")
assigner = build
picks = Array.new(4) { assigner.attendant_for("99988877766") }
assert_equal [users(:attendant), second, users(:attendant), second], picks
end
test "csv matches do not advance the rotation" do
second = User.create!(name: "Segunda Atendente", email: "segunda@ibft.com.br", password: "cob123")
assigner = build
assigner.attendant_for("11144477735")
assert_equal users(:attendant), assigner.attendant_for("99988877766")
assert_equal second, assigner.attendant_for("99988877766")
end
test "returns nil when there are no active attendants" do
assert_nil build(attendants: []).attendant_for("99988877766")
end end end ```
User.create! sem role usa o default attendant, e sem status usa o default active. Com
order(:id), a second vem depois da fixture users(:attendant), porque os ids de fixture são
hashes menores que os da sequence. Se não forem, ordenar o picks esperado por id.
- [ ] Step 2: Rodar e confirmar que falha
bash
cd modules/backend && bin/rails t test/use_cases/debits/attendant_assigner_test.rb
Expected: FAIL — NameError: uninitialized constant Debits::AttendantAssigner.
- [ ] Step 3: Implementar
app/use_cases/debits/attendant_assigner.rb:
```ruby require “csv”
module Debits # Resolves the attendant for an imported debit: the one named in the initial import CSV # (matched by document) when they are an active attendant, otherwise the next one in a # round-robin over the given attendants. class AttendantAssigner DOCUMENT_COLUMN = “CPF”.freeze ATTENDANT_COLUMN = “Usuário responsável”.freeze
def initialize(csv_path:, attendants:)
@attendants = attendants.to_a
@attendants_by_name = @attendants.index_by { |user| normalize_name(user.name) }
@names_by_document = load_names_by_document(csv_path)
@rotation_index = 0
end
def attendant_for(document)
from_csv(document) || next_in_rotation
end
private
def from_csv(document)
name = @names_by_document[normalize_document(document)]
@attendants_by_name[normalize_name(name)] if name
end
def next_in_rotation
return if @attendants.empty?
attendant = @attendants[@rotation_index % @attendants.size]
@rotation_index += 1
attendant
end
def load_names_by_document(path)
CSV.foreach(path, headers: true, encoding: "bom|utf-8").each_with_object({}) do |row, names|
document = normalize_document(row[DOCUMENT_COLUMN])
next if document.empty?
names[document] ||= row[ATTENDANT_COLUMN].to_s.strip
end
end
def normalize_document(document) = document.to_s.gsub(/\D/, "")
def normalize_name(name) = I18n.transliterate(name.to_s).downcase.squish end end ```
- [ ] Step 4: Rodar e confirmar que passa
bash
cd modules/backend && bin/rails t test/use_cases/debits/attendant_assigner_test.rb
Expected: PASS, com 8 runs e 0 failures.
- [ ] Step 5: Commit
bash
cd modules/backend && git add app/use_cases/debits/attendant_assigner.rb test/fixtures/files/initial_import.csv test/use_cases/debits/attendant_assigner_test.rb
git commit -m "feat: add attendant assigner with csv and round-robin"
Task 4: Use case Debits::ImportOverdueFromCheckout
Files:
- Create: app/use_cases/debits/import_overdue_from_checkout.rb
- Test: test/use_cases/debits/import_overdue_from_checkout_test.rb
Interfaces:
- Consumes:
- Task 1: Checkout::Payment.overdue_since, Checkout::Installment.active, Checkout::Customer, Checkout::OrganizationCustomer.
- Task 2: Debit::CHECKOUT_KIND_MAP, Installment::CHECKOUT_STATUS_MAP, Installment::CHECKOUT_BILLING_TYPE_MAP e as colunas provider_*.
- Task 3: Debits::AttendantAssigner.
- Consumes (Task 2): CheckoutImportRun.
- Produces: Debits::ImportOverdueFromCheckout.call(since:, batch_size:, csv_path:) → Success(result: { run: CheckoutImportRun, imported: Integer, skipped: [{ provider_payment_id:, reason: }], failed: [{ provider_payment_id:, reason: }] }). A execução fica gravada em checkout_import_runs.
O que esperar das fixtures, com since = 2026-01-01:
| Payment | Resultado |
|---|---|
overdue_standard (maria, CSV → Aretha) |
imported |
overdue_repayment (joana, já existe no nectar, fora do CSV) |
imported, round-robin |
overdue_settlement (carlos, fora do CSV) |
imported, round-robin |
overdue_renewal (admin_lead, CSV → Francisco = admin) |
imported, round-robin |
overdue_rafael (rafael, já existe no nectar sem provider_customer_id) |
imported, round-robin |
overdue_boundary_after (carlos, 00:05 BRT de 01/01) |
imported, round-robin |
overdue_no_email |
skipped :invalid_customer |
overdue_no_reference |
skipped :missing_reference |
overdue_unsupported_provider (PAGARME) |
skipped :unsupported_provider |
overdue_boundary_before, overdue_2025, overdue_deleted, paid |
fora da consulta |
Total: imported: 6, skipped: 3, failed: 0.
- [ ] Step 1: Escrever o teste que falha
test/use_cases/debits/import_overdue_from_checkout_test.rb:
```ruby require “test_helper”
module Debits class ImportOverdueFromCheckoutTest < ActiveSupport::TestCase def import(**options) Debits::ImportOverdueFromCheckout.call(csv_path: file_fixture(“initial_import.csv”), **options) end
def imported(reference) = Debit.find_by!(payment_provider: :asaas, provider_payment_id: reference)
def reasons(result, key) = result[key].to_h { |entry| [entry[:provider_payment_id], entry[:reason]] }
test "imports every overdue payment since the date and reports the outcome" do
result = import
assert_predicate result, :success?
assert_equal 6, result[:imported]
assert_equal({ "ref_no_email" => :invalid_customer, nil => :missing_reference,
"ref_carlos_pagarme" => :unsupported_provider }, reasons(result, :skipped))
assert_empty result[:failed]
end
test "copies provider and organization data to the debit" do
import
debit = imported("ref_maria")
assert_equal "asaas", debit.payment_provider
assert_equal "pay_maria_1", debit.provider_charge_id
assert_equal "inst_maria", debit.provider_installment_id
assert_equal "https://pay.ibft.com.br/payments/pay_maria_1", debit.provider_checkout_url
assert_equal "cus_ibft_maria", debit.provider_customer_id
assert_equal "ibft", debit.organization_slug
assert_equal 30_000, debit.total_cents
assert_equal "pending", debit.status
assert_not_nil debit.opened_at
end
test "uses the organization customer of the payment organization" do
import
assert_equal "cus_outra_joana", imported("ref_joana").provider_customer_id
assert_equal "outra-escola", imported("ref_joana").organization_slug
end
test "maps checkout kind to payment_type" do
import
assert_equal "installment", imported("ref_maria").payment_type
assert_equal "repayment_first", imported("ref_joana").payment_type
assert_equal "full_settlement", imported("ref_carlos_settlement").payment_type
assert_equal "renewal", imported("ref_admin_lead").payment_type
end
test "imports installments with mapped status, billing type and payment link" do
import
installments = imported("ref_maria").installments.ordered
assert_equal [1, 2, 3], installments.map(&:number)
assert_equal %w[paid overdue upcoming], installments.map(&:status)
assert_equal %w[pix boleto credit_card], installments.map(&:billing_type)
assert_equal %w[pay_maria_1 pay_maria_2 pay_maria_3], installments.map(&:provider_charge_id)
assert_equal "https://asaas.com/i/pay_maria_2", installments.second.payment_link
first = installments.first
assert_equal 10_000, first.amount_cents
assert_equal 500, first.discount_cents
assert_equal Date.new(2026, 2, 15), first.due_on
assert_equal Date.new(2026, 2, 14), first.paid_at.to_date
assert_equal 235, installments.second.interest_cents
assert_equal 3, imported("ref_maria").reload.installments_count
end
test "creates a new customer from checkout data assigned to the debit attendant" do
import
customer = Customer.find_by!(document: "11144477735")
assert_equal "Maria Souza", customer.name
assert_equal "maria.souza@example.com", customer.email
assert_equal "11999990001", customer.phone
assert_equal "cus_global_maria", customer.provider_customer_id
assert_equal users(:attendant), customer.user
assert_equal users(:attendant), imported("ref_maria").user
end
test "reuses an existing customer without overwriting its data" do
joana = customers(:joana)
import
assert_equal joana, imported("ref_joana").customer
joana.reload
assert_equal "Joana Ribeiro", joana.name
assert_equal "cus_000000000001", joana.provider_customer_id
assert_equal users(:attendant), joana.user
end
test "fills provider_customer_id of an existing customer when blank" do
import
assert_equal "cus_global_rafael", customers(:rafael).reload.provider_customer_id
end
test "round-robins attendants across pages when the CSV does not resolve" do
second = User.create!(name: "Segunda Atendente", email: "segunda@ibft.com.br", password: "cob123")
result = import(batch_size: 2)
assert_equal 6, result[:imported]
round_robin = %w[ref_joana ref_carlos_settlement ref_admin_lead ref_rafael ref_carlos_boundary]
assigned = round_robin.map { |reference| imported(reference).user }
assert_includes assigned, users(:attendant)
assert_includes assigned, second
assert_equal users(:attendant), imported("ref_maria").user
end
test "running again imports nothing new" do
import
assert_no_difference -> { Debit.count } do
result = import
assert_equal 0, result[:imported]
assert_equal 6, result[:skipped].count { |entry| entry[:reason] == :already_imported }
end
end
test "treats a unique violation as already imported" do
Debit.stub(:create!, ->(*) { raise ActiveRecord::RecordNotUnique, "duplicate key" }) do
result = import
assert_equal 0, result[:imported]
assert_equal :already_imported, reasons(result, :skipped)["ref_maria"]
end
end
test "isolates unexpected failures to the payment and rolls it back" do
original = Installment.method(:create!)
failing = lambda do |**attributes|
raise "boom" if attributes[:provider_charge_id] == "pay_maria_2"
original.call(**attributes)
end
Installment.stub(:create!, failing) do
result = import
assert_equal 5, result[:imported]
assert_equal "RuntimeError: boom", reasons(result, :failed)["ref_maria"]
end
assert_nil Debit.find_by(provider_payment_id: "ref_maria")
assert_nil Customer.find_by(document: "11144477735")
end
test "skips everything when there are no active attendants" do
User.active_attendants.update_all(status: :inactive)
result = import
assert_equal 0, result[:imported]
assert_equal :no_active_attendant, reasons(result, :skipped)["ref_maria"]
end
test "records the run with the complete lists" do
result = nil
assert_difference -> { CheckoutImportRun.count }, 1 do
result = import
end
run = result[:run]
assert_equal CheckoutImportRun.last, run
assert_equal Date.new(2026, 1, 1), run.since
assert_not_nil run.started_at
assert_not_nil run.finished_at
assert_equal 6, run.imported_count
assert_includes run.skipped, { "provider_payment_id" => "ref_no_email", "reason" => "invalid_customer" }
assert_equal({ "invalid_customer" => 1, "missing_reference" => 1, "unsupported_provider" => 1 },
run.skipped_by_reason)
assert_equal [], run.failed
end
test "records failures in the run" do
Debit.stub(:create!, ->(*) { raise "boom" }) do
run = import[:run]
assert_includes run.failed, { "provider_payment_id" => "ref_maria", "reason" => "RuntimeError: boom" }
end
end
test "rejects a non positive batch size" do
result = nil
assert_no_difference -> { CheckoutImportRun.count } do
result = import(batch_size: 0)
end
assert_predicate result, :failure?
assert_equal :invalid_attributes, result.type
end end end ```
- [ ] Step 2: Rodar e confirmar que falha
bash
cd modules/backend && bin/rails t test/use_cases/debits/import_overdue_from_checkout_test.rb
Expected: FAIL — NameError: uninitialized constant Debits::ImportOverdueFromCheckout.
- [ ] Step 3: Implementar
app/use_cases/debits/import_overdue_from_checkout.rb:
```ruby module Debits # Imports overdue checkout-api payments as debits, one transaction per payment. # Reads the checkout database page by page (keyset on payments.id) and never writes there. class ImportOverdueFromCheckout < Micro::Case DEFAULT_SINCE = Date.new(2026, 1, 1)
attribute :since, default: DEFAULT_SINCE
attribute :batch_size, default: 500, validates: { numericality: { only_integer: true, greater_than: 0 } }
attribute :csv_path, default: -> { Rails.root.join("db/initial_import.csv") }
def call!
run = CheckoutImportRun.create!(since: since, started_at: Time.current)
@summary = { imported: 0, skipped: [], failed: [] }
@assigner = AttendantAssigner.new(csv_path: csv_path, attendants: User.active_attendants.order(:id))
Checkout::Payment.overdue_since(since).find_in_batches(batch_size: batch_size) { |page| import_page(page) }
finish(run)
Success(result: @summary.merge(run: run))
end
private
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)
already_imported = Debit.where(provider_payment_id: page.filter_map(&:reference))
.pluck(:payment_provider, :provider_payment_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]],
already_imported: already_imported
)
end
end
def organization_customers_for(customer_ids, organization_ids)
Checkout::OrganizationCustomer
.where(customer_id: customer_ids, organization_id: organization_ids)
.order(:id)
.each_with_object({}) { |row, map| map[[row.customer_id, row.organization_id]] ||= row }
end
def import_payment(payment, customer:, installments:, organization_customer:, already_imported:)
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(payment, :already_imported) if already_imported.include?([provider, payment.reference])
return skip(payment, :missing_customer) if customer.nil?
return skip(payment, :missing_document) if customer.doc_number.to_s.gsub(/\D/, "").empty?
attendant = @assigner.attendant_for(customer.doc_number)
return skip(payment, :no_active_attendant) if attendant.nil?
ApplicationRecord.transaction do
debit = create_debit(payment, provider, find_or_create_customer(customer, attendant), attendant, organization_customer)
installments.each { |installment| create_installment(debit, installment) }
end
@summary[:imported] += 1
rescue ActiveRecord::RecordNotUnique
skip(payment, :already_imported)
rescue ActiveRecord::RecordInvalid => e
raise_or_skip_invalid(payment, e)
rescue StandardError => e
fail_payment(payment, e)
end
def raise_or_skip_invalid(payment, error)
return fail_payment(payment, error) unless error.record.is_a?(Customer)
Rails.logger.warn("[ImportOverdueFromCheckout] #{payment.reference}: #{error.message}")
skip(payment, :invalid_customer)
end
def find_or_create_customer(source, attendant)
document = source.doc_number.to_s.gsub(/\D/, "")
existing = Customer.find_by(document: document)
return fill_provider_customer_id(existing, source) if existing
Customer.create!(
name: source.name,
email: source.email,
phone: source.phone_number,
document: document,
document_type: document.length == Customer::DOCUMENT_LENGTHS["CNPJ"] ? "CNPJ" : "CPF",
provider_customer_id: source.gateway_id.presence,
user: attendant
)
end
def fill_provider_customer_id(customer, source)
if customer.provider_customer_id.blank? && source.gateway_id.present?
customer.update!(provider_customer_id: source.gateway_id)
end
customer
end
def create_debit(payment, provider, customer, attendant, organization_customer)
Debit.create!(
customer: customer,
user: attendant,
status: :pending,
opened_at: Time.current,
payment_type: Debit::CHECKOUT_KIND_MAP.fetch(payment.kind),
total_cents: to_cents(payment.total),
payment_provider: provider,
provider_payment_id: payment.reference,
provider_charge_id: payment.gateway_id,
provider_installment_id: payment.gateway_installment_id,
provider_checkout_url: payment.gateway_checkout_url,
provider_customer_id: organization_customer&.gateway_customer_id,
organization_slug: payment.organization_slug
)
end
def create_installment(debit, source)
status = Installment::CHECKOUT_STATUS_MAP[source.status]
return if status.nil?
Installment.create!(
debit: debit,
number: source.installment_number,
amount_cents: to_cents(source.total),
interest_cents: to_cents(source.interest_total),
discount_cents: to_cents(source.discount_total),
due_on: source.due_date,
paid_at: source.paid_date&.in_time_zone,
status: status,
provider_charge_id: source.gateway_id,
payment_link: source.payment_link,
billing_type: Installment::CHECKOUT_BILLING_TYPE_MAP[source.billing_type]
)
end
def finish(run)
run.update!(
finished_at: Time.current,
imported_count: @summary[:imported],
skipped: @summary[:skipped],
failed: @summary[:failed]
)
end
def to_cents(value) = (value.to_d * 100).round.to_i
def skip(payment, reason)
@summary[:skipped] << { provider_payment_id: payment.reference, reason: reason }
end
def fail_payment(payment, error)
Rails.logger.error("[ImportOverdueFromCheckout] #{payment.reference}: #{error.class}: #{error.message}")
@summary[:failed] << { provider_payment_id: payment.reference, reason: "#{error.class}: #{error.message}" }
end end end ```
Notas para quem for implementar:
- Installment.create!(**attributes) é chamado com keywords. O stub do teste de falha isolada
depende disso: failing recebe **attributes.
- Se o Micro::Case não aceitar lambda em default: para o csv_path, trocar por uma constante
DEFAULT_CSV_PATH = Rails.root.join("db/initial_import.csv").
- nil.to_d retorna 0, então to_cents(nil) é 0.
- O CheckoutImportRun é criado fora das transações por pagamento. Se o processo cair no meio, a
linha fica com finished_at nulo, e é isso que sinaliza uma execução incompleta.
- No jsonb, os motivos em symbol viram string. Em memória, o result continua com symbols.
- [ ] Step 4: Rodar e confirmar que passa, junto 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
Expected: PASS.
- [ ] Step 5: Commit
bash
cd modules/backend && git add app/use_cases/debits/import_overdue_from_checkout.rb test/use_cases/debits/import_overdue_from_checkout_test.rb
git commit -m "feat: import overdue checkout payments as debits"
Task 5: ImportOverdueCheckoutPaymentsJob
Files:
- Create: app/jobs/import_overdue_checkout_payments_job.rb
- Test: test/jobs/import_overdue_checkout_payments_job_test.rb
Interfaces:
- Consumes: Debits::ImportOverdueFromCheckout.call(**options) (Task 4).
- Produces: ImportOverdueCheckoutPaymentsJob.perform_later(since: nil).
- [ ] Step 1: Escrever o teste que falha
test/jobs/import_overdue_checkout_payments_job_test.rb:
```ruby require “test_helper”
class ImportOverdueCheckoutPaymentsJobTest < ActiveJob::TestCase RESULT = Struct.new(:data) do def = data[key] def success? = true end
def fake_result = RESULT.new({ imported: 2, skipped: [{ provider_payment_id: “a”, reason: :already_imported }], failed: [] })
test “delegates to the use case with the default since” do called_with = nil stub = ->(**options) { called_with = options; fake_result }
Debits::ImportOverdueFromCheckout.stub(:call, stub) do
ImportOverdueCheckoutPaymentsJob.perform_now
end
assert_equal({}, called_with) end
test “passes since as a date when given” do called_with = nil stub = ->(**options) { called_with = options; fake_result }
Debits::ImportOverdueFromCheckout.stub(:call, stub) do
ImportOverdueCheckoutPaymentsJob.perform_now(since: "2026-03-01")
end
assert_equal({ since: Date.new(2026, 3, 1) }, called_with) end
test “enqueues on the default queue” do assert_enqueued_with(job: ImportOverdueCheckoutPaymentsJob, queue: “default”) do ImportOverdueCheckoutPaymentsJob.perform_later end end end ```
- [ ] Step 2: Rodar e confirmar que falha
bash
cd modules/backend && bin/rails t test/jobs/import_overdue_checkout_payments_job_test.rb
Expected: FAIL — NameError: uninitialized constant ImportOverdueCheckoutPaymentsJob.
- [ ] Step 3: Implementar
app/jobs/import_overdue_checkout_payments_job.rb:
```ruby class ImportOverdueCheckoutPaymentsJob < ApplicationJob queue_as :default
def perform(since: nil) options = since ? { since: since.to_date } : {} result = Debits::ImportOverdueFromCheckout.call(**options)
log_summary(result) end
private
def log_summary(result) return Rails.logger.error(“[ImportOverdueCheckoutPaymentsJob] failed: #{result.type}”) unless result.success?
skipped = result[:skipped].group_by { |entry| entry[:reason] }.transform_values(&:size)
Rails.logger.info(
"[ImportOverdueCheckoutPaymentsJob] run=#{result[:run]&.id} imported=#{result[:imported]} " \
"skipped=#{skipped} failed=#{result[:failed].size}"
)
result[:failed].each do |entry|
Rails.logger.error("[ImportOverdueCheckoutPaymentsJob] #{entry[:provider_payment_id]}: #{entry[:reason]}")
end end end ```
- [ ] Step 4: Rodar e confirmar que passa
bash
cd modules/backend && bin/rails t test/jobs/import_overdue_checkout_payments_job_test.rb
Expected: PASS, com 3 runs.
- [ ] Step 5: Commit
bash
cd modules/backend && git add app/jobs/import_overdue_checkout_payments_job.rb test/jobs/import_overdue_checkout_payments_job_test.rb
git commit -m "feat: add job to import overdue checkout payments"
Task 6: CSV real, regra de negócio e verificação ponta a ponta
Files:
- Add: db/initial_import.csv (já existe, untracked)
- Create: .project/docs/rules/collections/checkout_overdue_import.md
- Modify: .project/docs/RULES.md
- Modify: .project/docs/README.md
- Modify: .project/docs/specs/20260928171636_import_overdue_checkout_payments.md (status: done)
Interfaces:
- Consumes: tudo acima.
- Produces: documentação da regra R-009.
- [ ] Step 1: Conferir o CSV real com o assigner
bash
cd modules/backend && bin/rails runner '
a = Debits::AttendantAssigner.new(csv_path: Rails.root.join("db/initial_import.csv"), attendants: User.active_attendants.order(:id))
puts a.instance_variable_get(:@names_by_document).size
puts a.instance_variable_get(:@names_by_document).values.tally
'
Expected: 3.584 documentos (3.586 linhas menos 2 CPFs repetidos), distribuídos entre Jamerson Firmino, Giovanna Coloia, Aretha Morais e Francisco Miguel.
- [ ] Step 2: Escrever a regra
.project/docs/rules/collections/checkout_overdue_import.md:
```markdown
id: R-009 title: Importação dos pagamentos atrasados do checkout scope: collections certainty: high created: 2026-09-28 updated: 2026-09-28 —
R-009 — Importação dos pagamentos atrasados do checkout
TLDR: todo pagamento
overduedo checkout criado desde 01/01/2026 (meia-noite de Brasília) vira umDebitpendingcom todas as parcelas ativas. O atendente vem dodb/initial_import.csvou do round-robin, e o marcador de importação é(payment_provider, provider_payment_id).
Spec: 20260928171636_import_overdue_checkout_payments.md
Given / When / Then
Dado um pagamento do checkout com status = overdue, gateway_deleted falso e created_at a
partir de 01/01/2026 00:00 em Brasília,
Quando o ImportOverdueCheckoutPaymentsJob roda,
Então o nectar cria um Debit pending com as parcelas paid/pending/overdue do pagamento,
ligado ao cliente do mesmo CPF (criado se não existir) e ao atendente definido pela regra abaixo.
Rodar de novo não duplica nada.
Regras
| ID | Regra |
|—|—|
| R-009.1 | Fonte: payments.status = 'overdue', created_at >= since convertido para o início do dia em Brasília, gateway_deleted falso ou nulo. Leitura paginada por keyset em payments.id |
| R-009.2 | payment_type vem de payments.kind: standard→installment, repayment→repayment_first, settlement→full_settlement, renewal→renewal |
| R-009.3 | payment_provider = payments.gateway em minúsculas. Fora de asaas/hotmart, o pagamento é pulado (unsupported_provider) |
| R-009.4 | Marcador de importação: provider_payment_id = payments.reference (o externalReference no Asaas), único por payment_provider. O que já foi importado é pulado (already_imported) |
| R-009.5 | provider_charge_id = payments.gateway_id, provider_installment_id = payments.gateway_installment_id, provider_checkout_url = payments.gateway_checkout_url, provider_customer_id = cus_ da organization do pagamento, organization_slug = organizations.slug |
| R-009.6 | Parcelas paid→paid, pending→upcoming, overdue→overdue. As demais não entram. provider_charge_id = installments.gateway_id, e payment_link e billing_type são copiados |
| R-009.7 | Cliente é buscado pelo CPF. Se existe, não é alterado, exceto preencher provider_customer_id vazio. Se não existe, é criado com os dados do checkout e o atendente do débito. Se a criação é inválida, o pagamento é pulado (invalid_customer) |
| R-009.8 | Atendente: nome do CSV (primeira linha do CPF) entre os atendentes ativos, ignorando caixa e acento. Senão, round-robin por id entre os atendentes ativos, contínuo na execução. O atendente atual do cliente não é considerado |
| R-009.9 | Cada pagamento roda na própria transação. Uma falha inesperada vai para failed e não interrompe o import |
| R-009.10 | Débito importado não se apaga (usar cancelled). Se for apagado, o próximo import traz o pagamento de novo |
| R-009.11 | Toda execução grava uma linha em checkout_import_runs, com as listas completas de pulados e falhas. finished_at nulo indica uma execução que não terminou |
```
.project/docs/RULES.md: acrescentar na tabela, depois da linha do R-008:
markdown
| R-009 | Importação dos pagamentos atrasados do checkout | collections | high | [rules/collections/checkout_overdue_import.md](rules/collections/checkout_overdue_import.md) |
.project/docs/README.md: o plano já está no índice. Na tabela de specs/, trocar o status da spec 20260928171636_import_overdue_checkout_payments.md
para done. Na spec, trocar status: proposed por status: done e atualizar updated.
- [ ] Step 3: Verificação ponta a ponta em development
Com o banco local do checkout rodando, com a porta do .env do checkout-api (5001 por padrão):
bash
cd modules/backend && CHECKOUT_DATABASE_URL=postgres://postgres:postgres@localhost:5001/zeus_pay_api_development \
bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now' && tail -n 20 log/development.log | grep ImportOverdueCheckoutPaymentsJob
Expected: uma linha imported=N skipped={...} failed=0. Depois:
bash
cd modules/backend && CHECKOUT_DATABASE_URL=postgres://postgres:postgres@localhost:5001/zeus_pay_api_development bin/rails runner '
puts Debit.where.not(provider_payment_id: nil).count
d = Debit.where.not(provider_payment_id: nil).includes(:installments, :customer, :user).first
pp d.attributes.slice("payment_provider", "provider_payment_id", "provider_charge_id", "provider_installment_id", "provider_customer_id", "organization_slug", "payment_type")
pp d.installments.map { |i| i.attributes.slice("number", "status", "provider_charge_id", "billing_type") }
ImportOverdueCheckoutPaymentsJob.perform_now
'
Expected:
- a contagem é igual ao imported;
- os campos batem com o pagamento no checkout;
- o segundo perform_now loga imported=0;
- CheckoutImportRun.count é 2, e CheckoutImportRun.first.skipped_by_reason e .failed listam os
pagamentos que ficaram de fora.
Conferir também uma escrita:
bash
cd modules/backend && CHECKOUT_DATABASE_URL=postgres://postgres:postgres@localhost:5001/zeus_pay_api_development \
bin/rails runner 'Checkout::Payment.first.update!(status: "paid")'
Expected: ActiveRecord::ReadOnlyRecord.
- [ ] Step 4: Rodar a suíte inteira
bash
cd modules/backend && bin/rails t
Expected: PASS.
- [ ] Step 5: Commit
bash
git add modules/backend/db/initial_import.csv .project/docs/rules/collections/checkout_overdue_import.md .project/docs/RULES.md .project/docs/README.md .project/docs/specs/20260928171636_import_overdue_checkout_payments.md
git commit -m "docs: rule R-009 checkout overdue import"