Importação dos pagamentos atrasados do checkout — Plano de implementação

TLDR: conexão somente leitura com o banco do checkout, colunas provider_* e registro checkout_import_runs no domínio, AttendantAssigner (CSV + round-robin), o use case Debits::ImportOverdueFromCheckout paginado por keyset e o job ImportOverdueCheckoutPaymentsJob.

Spec: .project/docs/specs/20260928171636_import_overdue_checkout_payments.md Branch: 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 em modules/backend, nunca com make.
  • 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? sempre true, e usuário com só SELECT em 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 overdue do checkout criado desde 01/01/2026 (meia-noite de Brasília) vira um Debit pending com todas as parcelas ativas. O atendente vem do db/initial_import.csv ou 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"