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 statusoverduecriados desde 01/01/2026, e converte cada um emDebitcom suas parcelas. O atendente vem dodb/initial_import.csvou, 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_deletedfalso ou nulo), umDebitcom 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. ContracteNegotiationpara 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, comsinceconfigurável e default2026-01-01COALESCE(payments.gateway_deleted, false) = false- leitura paginada por
payments.idcrescente (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:
Checkout::Customer.where(id: customer_ids)Checkout::Installment.where(payment_id: payment_ids)comgateway_deletedfalso ou nuloCheckout::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 menorid.
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.documentusando ocustomers.doc_numberdo checkout. - Se o cliente existe: é reaproveitado sem alterar nome, email ou telefone. O
provider_customer_id(renomeado degateway_customer_ref) é preenchido comcustomers.gateway_idsó quando estiver vazio. - Se não existe: é criado com
name,email,phone_numberedoc_number, maisprovider_customer_id = customers.gateway_ideuserigual ao atendente do débito. - O
user_idde um cliente existente não é alterado.
Atendente
- Procura o CPF do cliente no CSV. Havendo CPF repetido, vale a primeira linha.
- Procura o nome da coluna
Usuário responsávelentre os usuários ativos com roleattendantouadmin(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. - 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_attendantsordenados porid, continuando de onde parou dentro da mesma execução. Admin nunca entra no rodízio. - O atendente de um cliente que já existe não é considerado.
- 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.
- 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 apayments.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. UmActiveRecord::RecordNotUniquenesse índice também é tratado como:already_imported, com rollback só daquele pagamento. Violação de qualquer outro índice vai parafailed. O import não é feito para rodar em paralelo: uma execução por vez. - Pagamento com
referencevazio é pulado com o motivo:missing_reference. - Pagamento com
gatewayfora deDebit.payment_provider.valuesé pulado com o motivo:unsupported_provider. Hoje o checkout só temASAAS. - 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 oprovider_customer_id;:no_active_attendant: não existe nenhum atendente ativo.
- Qualquer outra exceção num pagamento é registrada em
failede não interrompe o job. - O use case retorna
Success(result: { run:, imported:, skipped:, failed: }).skippedefailedsã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ãocheckoutem cada ambiente.- development, staging e production:
url: <%= ENV["CHECKOUT_DATABASE_URL"] %>,database_tasks: false. A sessão também é aberta comdefault_transaction_read_only = on, então o Postgres recusa qualquer escrita, inclusiveupdate_alle SQL cru. Em produção, o usuário do banco deve ter só permissão deSELECT. - test: banco próprio
cobranca_api_checkout_test, comdatabase_tasks: true,migrations_paths: db/checkout_migrateeschema_dump: checkout_schema.rb, para as fixtures funcionarem.
- development, staging e production:
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 }ereadonly?sempretrue. As fixtures continuam funcionando, porque são inseridas por SQL e não passam porreadonly?.Checkout::Payment(payments):belongs_to :customer,has_many :installments, e o scopeoverdue_since(date)com os joins SQL emcheckoutseorganizations, que expõeorganization_ideorganization_slugno 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_linkebilling_type(strings, nullable). - Migration
create_checkout_import_runs, com as colunas de Registro da execução. app/models/checkout_import_run.rb: validasinceestarted_at, e temskipped_by_reason, que conta os pulados por motivo.- Migration de rename para seguir o padrão
provider_*:installments.gateway_ref → provider_charge_idcustomers.gateway_customer_ref → provider_customer_id
Cada rename usa
rename_column, que também renomeia o índice único parcial. O índice passa a se chamarindex_installments_on_provider_charge_idouindex_customers_on_provider_customer_id, e oWHERE ... IS NOT NULLacompanha o novo nome da coluna. app/models/installment.rbeapp/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.ymletest/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),renewalno enumpayment_type, o mapaCHECKOUT_KIND_MAPe a validação de unicidade deprovider_payment_idcomscope: :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 mapaCHECKOUT_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(defaultDate.new(2026, 1, 1)),batch_size(default500) ecsv_path(defaultRails.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 :defaulteperform(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}.ymle as linhas decheckouts/organizationsnecessárias, comset_fixture_classapontando para os modelsCheckout::. Cobrem: pagamento atrasado, pagamento pago, pagamento anterior a 2026, pagamento comgateway_deleted, e cadakind.test/fixtures/files/initial_import.csv: CSV pequeno para os testes.test/models/checkout/payment_test.rb: o scopeoverdue_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 porid.test/use_cases/debits/import_overdue_from_checkout_test.rb: import completo, mapeamento dekind, de status e debilling_typedas parcelas,payment_linkcopiado, 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,RecordNotUniquedo índice do marcador tratado como:already_importede de outro índice comofailed, e os pulosmissing_reference,unsupported_provider,missing_customeremissing_document, falha isolada e paginação (combatch_size: 2e 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 eskipped_by_reason.- No teste do use case: a execução fica gravada com
finished_at,imported_count,skippedefailed. test/jobs/import_overdue_checkout_payments_job_test.rb: o job chama o use case comsince.
Como verificar
bin/rails temmodules/backendpassa.- Em development, apontando
CHECKOUT_DATABASE_URLpara o banco local do checkout (zeus_pay_api_development):bin/rails runner 'ImportOverdueCheckoutPaymentsJob.perform_now';- o log mostra
imported,skippedefailed; Debit.where.not(provider_payment_id: nil).counté igual aimported;- num débito amostrado,
organization_slug,payment_provider,provider_installment_id,provider_customer_ide 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 temfinished_ate as listas completas.
- Uma tentativa de escrita por
Checkout::PaymentlevantaActiveRecord::ReadOnlyRecord.
Documentação
- Criar
.project/docs/rules/collections/checkout_overdue_import.mdcom 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.