Importação dos pagamentos atrasados do checkout como débitos

TLDR: um job roda o use case Debits::ImportOverdueFromCheckout. O use case lê direto do banco do checkout os pagamentos com status overdue criados desde 01/01/2026, e converte cada um em Debit com suas parcelas. O atendente vem do db/initial_import.csv ou, na falta dele, de um round-robin entre os atendentes ativos. O débito guarda organization slug, ids do Asaas e URL do checkout, para a integração com o Asaas que vem depois.

Asana: USER-01 — Importação inicial de devedores

Contexto

Os devedores de hoje estão no checkout (checkout-api), que cobra pelo Asaas. O nectar-charges precisa começar com essa carteira: todo pagamento atrasado desde 01/01/2026 vira um débito para a equipe de cobrança trabalhar. A carteira de atendentes já está distribuída numa planilha (db/initial_import.csv, 3.586 linhas, colunas Lead,Nome,CPF,E-mail,Celular,Usuário responsável), que precisa ser respeitada.

Cada organization do checkout tem conta própria no Asaas. Logo, o mesmo cliente tem um cus_ diferente em cada organization. Para reparcelar, cobrar e negativar na conta certa, o débito precisa guardar a organization e os ids do Asaas daquela conta.

Objetivos

  • Ler o banco do checkout por uma conexão secundária somente leitura.
  • Importar, para cada pagamento atrasado (payments.status = 'overdue', created_at >= 2026-01-01, gateway_deleted falso ou nulo), um Debit com todas as parcelas do pagamento.
  • Achar o cliente pelo CPF ou criar um novo, sem sobrescrever dados de quem já existe.
  • Atribuir o atendente pelo CSV, com round-robin quando o CSV não resolve.
  • Guardar no débito o que a integração com o Asaas vai precisar.
  • Permitir rodar de novo sem duplicar nada.
  • Fazer a importação num use case chamado por um job.

Fora de escopo

  • Chamadas à API do Asaas, tokens das organizations e sincronização de status depois do import.
  • Import recorrente: o job roda sob demanda, não entra em config/recurring.yml.
  • Contract e Negotiation para os débitos importados.
  • ProductDebit: ver Importação dos produtos do checkout.
  • Tela ou endpoint para disparar o import ou ver o resultado dele.
  • Atualizar um débito que já foi importado: o reimport só pula.
  • RegistryNegativation.gateway_ref: é o id da negativação no bureau, não no provedor de pagamento, e fica com o nome atual.

Regras

Fonte: pagamentos do checkout

A consulta segue estes critérios (o filtro de parcela atrasada foi acrescentado em Importar só pagamentos com parcela em atraso):

  • payments.status = 'overdue'
  • payments.created_at >= since.in_time_zone.beginning_of_day, com since configurável e default 2026-01-01
  • COALESCE(payments.gateway_deleted, false) = false
  • leitura paginada por payments.id crescente (ver Paginação), o que também deixa o round-robin determinístico

Os dois apps usam time_zone = "Brasilia", mas o Postgres grava created_at em UTC. Por isso a data é convertida para o início do dia em Brasília: 01/01/2026 00:00 BRT, que é 2026-01-01 03:00:00 UTC. Sem essa conversão, entrariam pagamentos de 31/12/2025 a partir das 21h.

A organization chega por payments.checkout_id → checkouts.organization_id → organizations, via join SQL, sem model para checkouts nem para organizations. O que importa é organizations.slug, não checkouts.slug.

Paginação

A consulta nunca carrega a lista inteira. Ela é lida em páginas por keyset em payments.id, usando Checkout::Payment.overdue_since(since).find_in_batches(batch_size:). O batch_size é um atributo do use case, com default 500. Cada página roda este SQL:

sql SELECT payments.*, organizations.id AS organization_id, organizations.slug AS organization_slug FROM payments INNER JOIN checkouts ON checkouts.id = payments.checkout_id INNER JOIN organizations ON organizations.id = checkouts.organization_id WHERE payments.status = 'overdue' AND payments.created_at >= '2026-01-01 03:00:00' AND (payments.gateway_deleted = false OR payments.gateway_deleted IS NULL) AND payments.id > :last_id_da_pagina_anterior -- omitido na 1ª página ORDER BY payments.id LIMIT 500;

Keyset em vez de OFFSET por dois motivos:

  • o custo de cada página é constante;
  • se o checkout mudar um pagamento durante o import, nenhum registro é pulado nem lido duas vezes.

Como a ordem vem do find_in_batches, o scope overdue_since não declara order.

Para cada página, os dados relacionados são carregados em 3 consultas, e não uma por pagamento:

  1. Checkout::Customer.where(id: customer_ids)
  2. Checkout::Installment.where(payment_id: payment_ids) com gateway_deleted falso ou nulo
  3. Checkout::OrganizationCustomer.where(customer_id: customer_ids, organization_id: organization_ids), indexado em memória pelo par [customer_id, organization_id]. Com mais de uma linha para o mesmo par, vale a de menor id.

Tudo roda num único job, em sequência. Não há um job por página porque o ponteiro do round-robin é compartilhado pela execução inteira, e o volume esperado (milhares de pagamentos) não justifica paralelizar.

Débito

Campo do Debit Origem
provider_payment_id (novo, único) payments.reference: o externalReference que o checkout enviou ao Asaas e que volta nos webhooks. É também o marcador de importação
organization_slug (novo) organizations.slug
payment_provider (novo) payments.gateway em minúsculas (ASAAS→asaas). Diz em qual provedor de pagamento o débito está
provider_charge_id (novo) payments.gateway_id (pay_...): id da cobrança no provedor, a 1ª do parcelamento
provider_installment_id (novo) payments.gateway_installment_id (código do parcelamento)
provider_checkout_url (novo) payments.gateway_checkout_url
provider_customer_id (novo) organization_customers.gateway_customer_id da organization do pagamento (cus_...)
payment_type payments.kind: standard→installment, repayment→repayment_first, settlement→full_settlement, renewal→renewal
total_cents payments.total × 100, arredondado
status pending
opened_at momento do import
user atendente (ver abaixo)

renewal entra como valor novo no enum Debit.payment_type.

Parcelas

Toda parcela do pagamento (installments.payment_id, com gateway_deleted falso ou nulo) vira um Installment:

Campo do Installment Origem
number installment_number
amount_cents total × 100
interest_cents interest_total × 100 (0 se nulo)
discount_cents discount_total × 100 (0 se nulo)
due_on due_date
paid_at paid_date, no início do dia
provider_charge_id (renomeado de gateway_ref) gateway_id (pay_... da cobrança). Usado para achar a parcela no Asaas (GET /v3/payments/{id}) e nos webhooks (payment.id)
payment_link (novo) payment_link, o link de pagamento da parcela (boleto/PIX/cartão)
billing_type (novo) billing_type: BOLETO→boleto, PIX→pix, CREDIT_CARD→credit_card, ou nulo se vazio
status paid→paid, pending→upcoming, overdue→overdue

Parcelas com outro status (draft, creating_on_gateway, refunded, deleted_or_canceled_by_new_payment) não entram.

Cliente

  • Busca por Customer.document usando o customers.doc_number do checkout.
  • Se o cliente existe: é reaproveitado sem alterar nome, email ou telefone. O provider_customer_id (renomeado de gateway_customer_ref) é preenchido com customers.gateway_id só quando estiver vazio.
  • Se não existe: é criado com name, email, phone_number e doc_number, mais provider_customer_id = customers.gateway_id e user igual ao atendente do débito.
  • O user_id de um cliente existente não é alterado.

Atendente

  1. Procura o CPF do cliente no CSV. Havendo CPF repetido, vale a primeira linha.
  2. Procura o nome da coluna Usuário responsável entre os usuários ativos com role attendant ou admin (User.active_debit_assignees), porque há admins que também atendem, como o Francisco Miguel. A comparação ignora maiúsculas, minúsculas e acentos.
  3. Se o CPF não está no CSV, ou o nome não corresponde a um usuário ativo desses roles, usa o round-robin: rodízio só entre User.active_attendants ordenados por id, continuando de onde parou dentro da mesma execução. Admin nunca entra no rodízio.
  4. O atendente de um cliente que já existe não é considerado.
  5. Dentro da mesma execução, o mesmo CPF fica sempre com o mesmo atendente: se o cliente tem vários pagamentos atrasados, todos os débitos dele vão para quem foi escolhido no primeiro.
  6. O atendente só é sorteado depois de validar o cliente. Um pagamento pulado por cliente inválido não consome a vez de ninguém no round-robin.

O CSV é lido com CSV (há campos com vírgula entre aspas) e o CPF é tratado como string, porque tem zeros à esquerda.

Idempotência e falhas

  • Marcador de importação: debits.provider_payment_id, com valor igual a payments.reference. Antes de processar uma página, o use case busca de uma vez os que já foram importados: Debit.where(provider_payment_id: page.filter_map(&:reference)).pluck(:payment_provider, :provider_payment_id).to_set. O par [payment_provider, reference] que estiver nesse conjunto é pulado com o motivo :already_imported, sem abrir transação.
  • Garantia no banco: índice único parcial em debits (payment_provider, provider_payment_id) (WHERE provider_payment_id IS NOT NULL, porque débitos criados fora do import não têm esse valor). O índice é composto porque ids de provedores diferentes, como Asaas e Hotmart, podem coincidir. Um ActiveRecord::RecordNotUnique nesse índice também é tratado como :already_imported, com rollback só daquele pagamento. Violação de qualquer outro índice vai para failed. O import não é feito para rodar em paralelo: uma execução por vez.
  • Pagamento com reference vazio é pulado com o motivo :missing_reference.
  • Pagamento com gateway fora de Debit.payment_provider.values é pulado com o motivo :unsupported_provider. Hoje o checkout só tem ASAAS.
  • Se um débito importado for apagado, o próximo import traz aquele pagamento de novo. Débito importado não se apaga: usa-se o status cancelled.
  • Cada pagamento roda na própria transação: débito, parcelas e cliente novo.
  • Um pagamento é pulado e registrado com o motivo quando:
    • :missing_customer: o cliente do pagamento não existe no checkout;
    • :missing_document: o cliente não tem CPF/CNPJ;
    • :invalid_customer: o cliente novo não passa na validação (email já usado por outro CPF, email ou telefone ausente, documento inválido), ou o cliente existente fica inválido ao receber o provider_customer_id;
    • :no_active_attendant: não existe nenhum atendente ativo.
  • Qualquer outra exceção num pagamento é registrada em failed e não interrompe o job.
  • O use case retorna Success(result: { run:, imported:, skipped:, failed: }). skipped e failed são listas de { provider_payment_id:, reason: }, e o job loga o resumo.

Registro da execução

Cada execução grava uma linha em checkout_import_runs, no banco do nectar:

Coluna Conteúdo
since (date, not null) data de corte usada
started_at (datetime, not null) gravado no início do use case
finished_at (datetime) gravado no fim. Se ficar nulo, a execução não terminou (por exemplo, caiu a conexão com o checkout)
imported_count (integer, default 0) quantidade importada
skipped (jsonb, default []) lista completa de { provider_payment_id, reason } dos pulados
failed (jsonb, default []) lista completa de { provider_payment_id, reason } das falhas

A linha é criada antes da primeira página e atualizada no final. Para consultar: CheckoutImportRun.last.skipped ou CheckoutImportRun.last.skipped_by_reason. Os motivos ficam como string no jsonb.

Fluxo de execução

Componentes

```mermaid flowchart LR subgraph trigger[Disparo] R[“bin/rails runner /
perform_later”] end

subgraph nectar[nectar-charges] J[“ImportOverdueCheckoutPaymentsJob
(Solid Queue, :default)”] UC[“Debits::ImportOverdueFromCheckout
(Micro::Case)”] AA[“Debits::AttendantAssigner
(CSV + round-robin)”] CSV[(“db/initial_import.csv”)] subgraph ro[“Models de leitura (CheckoutRecord, readonly)”] CP[“Checkout::Payment
.overdue_since”] CC[“Checkout::Customer”] CI[“Checkout::Installment”] COC[“Checkout::OrganizationCustomer”] end subgraph dom[Domínio] U[“User.active_attendants /
active_debit_assignees”] CU[“Customer”] D[“Debit”] I[“Installment”] end DBN[(“Postgres nectar
(primary)”)] end

DBC[(“Postgres checkout
CHECKOUT_DATABASE_URL
SELECT only”)]

R –> J –> UC UC –> RUN[“CheckoutImportRun”] RUN –> DBN UC –> AA AA –> CSV AA –> U UC –> CP & CC & CI & COC CP & CC & CI & COC –> DBC UC –> CU & D & I U & CU & D & I –> DBN ```

Sequência de uma execução

```mermaid sequenceDiagram autonumber participant J as ImportOverdueCheckoutPaymentsJob participant UC as Debits::ImportOverdueFromCheckout participant AA as Debits::AttendantAssigner participant CK as Postgres checkout (readonly) participant NC as Postgres nectar

J-»UC: call(since:, batch_size:, csv_path:) UC-»NC: CheckoutImportRun create!(since, started_at) UC-»AA: new(csv_path:, attendants: User.active_attendants, csv_assignees: User.active_debit_assignees) AA-»AA: carrega CSV (CPF → nome), 1ª linha vence loop cada página (keyset por payments.id, LIMIT batch_size) UC-»CK: Checkout::Payment.overdue_since(since) … id > last_id UC-»CK: customers, installments, organization_customers da página (3 queries) UC-»NC: debits já importados (provider_payment_id IN references da página) loop cada pagamento da página alt já importado UC-»UC: skipped « { reference, :already_imported } else novo UC-»NC: Customer find_by(document) ou new, e valida UC-»AA: attendant_for(document) AA–»UC: User do CSV, já escolhido para o CPF, ou próximo do round-robin UC-»NC: BEGIN UC-»NC: Customer save (novo com o atendente, ou existente com provider_customer_id) UC-»NC: Debit create (checkout/gateway/org fields) UC-»NC: Installment create × N UC-»NC: COMMIT Note over UC,NC: erro de validação → ROLLBACK + skipped
outra exceção → ROLLBACK + failed end end end UC-»NC: CheckoutImportRun update!(finished_at, imported_count, skipped, failed) UC–»J: Success(run:, imported:, skipped:, failed:) J-»J: Rails.logger.info(resumo) ```

Mudanças

Todos os caminhos são relativos a modules/backend/.

Conexão com o checkout

  • config/database.yml: nova conexão checkout em cada ambiente.
    • development, staging e production: url: <%= ENV["CHECKOUT_DATABASE_URL"] %>, database_tasks: false. A sessão também é aberta com default_transaction_read_only = on, então o Postgres recusa qualquer escrita, inclusive update_all e SQL cru. Em produção, o usuário do banco deve ter só permissão de SELECT.
    • test: banco próprio cobranca_api_checkout_test, com database_tasks: true, migrations_paths: db/checkout_migrate e schema_dump: checkout_schema.rb, para as fixtures funcionarem.
  • db/checkout_schema.rb: schema mínimo, só com as tabelas e colunas que o import lê (payments, installments, customers, checkouts, organizations, organization_customers), espelhando os tipos do checkout. Usado só em test.
  • .env.example: CHECKOUT_DATABASE_URL.

Models de leitura (app/models/checkout/)

  • app/models/checkout_record.rb: self.abstract_class = true, connects_to database: { writing: :checkout, reading: :checkout } e readonly? sempre true. As fixtures continuam funcionando, porque são inseridas por SQL e não passam por readonly?.
  • Checkout::Payment (payments): belongs_to :customer, has_many :installments, e o scope overdue_since(date) com os joins SQL em checkouts e organizations, que expõe organization_id e organization_slug no select.
  • Checkout::Installment (installments).
  • Checkout::Customer (customers).
  • Checkout::OrganizationCustomer (organization_customers).

Todos com self.table_name explícito.

Domínio

  • Migration em debits: provider_payment_id (string, índice único em [payment_provider, provider_payment_id] onde não nulo), organization_slug, payment_provider, provider_charge_id, provider_installment_id, provider_checkout_url, provider_customer_id (strings).
  • Migration em installments: payment_link e billing_type (strings, nullable).
  • Migration create_checkout_import_runs, com as colunas de Registro da execução.
  • app/models/checkout_import_run.rb: valida since e started_at, e tem skipped_by_reason, que conta os pulados por motivo.
  • Migration de rename para seguir o padrão provider_*:
    • installments.gateway_ref → provider_charge_id
    • customers.gateway_customer_ref → provider_customer_id

    Cada rename usa rename_column, que também renomeia o índice único parcial. O índice passa a se chamar index_installments_on_provider_charge_id ou index_customers_on_provider_customer_id, e o WHERE ... IS NOT NULL acompanha o novo nome da coluna.

  • app/models/installment.rb e app/models/customer.rb: a validação de unicidade passa para as colunas novas, e as anotações de schema são atualizadas.
  • test/fixtures/installments.yml e test/fixtures/customers.yml: o atributo é renomeado, e as anotações de schema dos fixtures e dos testes de model também são atualizadas.
  • app/models/debit.rb: enumerize :payment_provider, in: %i[asaas hotmart] (sem default, aceita nulo para débitos criados fora de um provedor), renewal no enum payment_type, o mapa CHECKOUT_KIND_MAP e a validação de unicidade de provider_payment_id com scope: :payment_provider (allow_nil).
  • app/models/installment.rb: CHECKOUT_STATUS_MAP (paid, pending, overdue), enumerize :billing_type, in: %i[boleto pix credit_card] (sem default, aceita nulo) e o mapa CHECKOUT_BILLING_TYPE_MAP.

Use case, apoio e job

  • app/use_cases/debits/import_overdue_from_checkout.rb: use case (Micro::Case) que orquestra as regras acima. Atributos: since (default Date.new(2026, 1, 1)), batch_size (default 500) e csv_path (default Rails.root.join("db/initial_import.csv")).
  • app/use_cases/debits/attendant_assigner.rb: PORO que carrega o CSV em memória (CPF → nome), resolve o atendente e mantém o ponteiro do round-robin.
  • app/jobs/import_overdue_checkout_payments_job.rb: queue_as :default e perform(since: nil), que chama o use case e loga o resumo.
  • db/initial_import.csv: passa a ser versionado.

Testes

  • test/fixtures/checkout/{payments,installments,customers,organization_customers}.yml e as linhas de checkouts/organizations necessárias, com set_fixture_class apontando para os models Checkout::. Cobrem: pagamento atrasado, pagamento pago, pagamento anterior a 2026, pagamento com gateway_deleted, e cada kind.
  • test/fixtures/files/initial_import.csv: CSV pequeno para os testes.
  • test/models/checkout/payment_test.rb: o scope overdue_since, incluindo o corte de fuso (pagamento de 31/12/2025 às 22h BRT fica de fora; 01/01/2026 às 00h05 BRT entra) e a ordem por id.
  • test/use_cases/debits/import_overdue_from_checkout_test.rb: import completo, mapeamento de kind, de status e de billing_type das parcelas, payment_link copiado, cliente existente e novo, atendente pelo CSV, round-robin, admin do CSV recebendo o débito sem entrar no rodízio, mesmo CPF com o mesmo atendente, cliente inválido (novo ou existente) sem consumir o rodízio, reimport sem duplicar, RecordNotUnique do índice do marcador tratado como :already_imported e de outro índice como failed, e os pulos missing_reference, unsupported_provider, missing_customer e missing_document, falha isolada e paginação (com batch_size: 2 e 5 pagamentos, todos importados em 3 páginas, e o round-robin continua entre as páginas).
  • test/use_cases/debits/attendant_assigner_test.rb: normalização de nome, CPF repetido, rodízio, admin só pelo CSV e o mesmo atendente para o mesmo CPF.
  • test/models/checkout_import_run_test.rb: validações e skipped_by_reason.
  • No teste do use case: a execução fica gravada com finished_at, imported_count, skipped e failed.
  • test/jobs/import_overdue_checkout_payments_job_test.rb: o job chama o use case com since.

Como verificar

  1. bin/rails t em modules/backend passa.
  2. Em development, apontando CHECKOUT_DATABASE_URL para o banco local do checkout (zeus_pay_api_development):
    • bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now';
    • o log mostra imported, skipped e failed;
    • Debit.where.not(provider_payment_id: nil).count é igual a imported;
    • num débito amostrado, organization_slug, payment_provider, provider_installment_id, provider_customer_id e as parcelas batem com o checkout;
    • clientes do CSV ficam com o atendente da planilha;
    • rodar de novo dá imported: 0;
    • CheckoutImportRun.count é 2, e cada linha tem finished_at e as listas completas.
  3. Uma tentativa de escrita por Checkout::Payment levanta ActiveRecord::ReadOnlyRecord.

Documentação

  • Criar .project/docs/rules/collections/checkout_overdue_import.md com as regras de origem, mapeamento, atendente e idempotência.
  • Registrar a regra em .project/docs/RULES.md, e esta spec e o futuro plano no .project/docs/README.md.