Evento de parcela atrasada para o accounts — Plano de implementação
TLDR: seis tasks TDD que fazem o webhook de
PAYMENT_OVERDUEdo Asaas gravar umOutboxEventaccounts.installment_overduecom o pagamento inteiro, despachado para oPOST /api/v1/eventsdoaccounts.
Spec:
.project/docs/specs/20260922093318_accounts_installment_overdue_event.mdBranch:feat/accounts-installment-overdue-event
Arquitetura: o payload do INSTALLMENT_OVERDUE é o do PURCHASE_CREATED mais
purchased_at, status por parcela e overdue_installment — então
Accounts::InstallmentOverdueSerializer herda de Accounts::PurchaseSerializer em vez de
recompor o hash. O orquestrador Accounts::DispatchInstallmentOverdue espelha
Accounts::DispatchPurchaseCreated: busca as parcelas ao vivo no Asaas, grava o OutboxEvent
e enfileira Outbox::DispatchEventsJob, engolindo qualquer falha para nunca derrubar o
webhook. Um step novo no PaymentsFlow decide quando chamar.
Stack: Rails 8, RSpec, FactoryBot, ucasy (Ucasy::Base/Ucasy::Flow),
active_model_serializers, HTTParty, Sidekiq (ApplicationJob).
Restrições globais
- Baseline verde antes de começar:
make checkem7033256passa (exit 0, verificado). - Código, nomes e descrições de teste em inglês; documentação em
.project/docs/em português. make run.test path=<path>é o runner;make checkroda lint + teste + audit.- Commits: uma linha, no máximo 60 caracteres, sem menção a IA.
- Nenhum arquivo é commitado antes de a task inteira estar verde.
Ordem e paralelismo
Task 1 ──> Task 2 ──> Task 3 ──> Task 5 ──> Task 6
Task 4 ─────────────────────────────┘
Task 4 (dispatcher + registry) não depende de nenhuma outra e pode rodar em paralelo com 1→2→3. Task 5 precisa da 3. Task 6 (docs) fecha, depois de tudo verde.
Task 1: PurchaseSerializer traduz e filtra o status da parcela
O FetchFromGateway já devolve o status normalizado e o PurchaseSerializer hoje o
descarta. Traduzir aqui é o que permite o evento de atraso herdar a lista pronta.
Files:
- Modify: app/serializers/accounts/purchase_serializer.rb
- Test: spec/serializers/accounts/purchase_serializer_spec.rb
- Test (propagação): spec/services/accounts/dispatch_purchase_created_spec.rb
Interfaces:
- Consumes: Asaas::Installments::FetchFromGateway.normalize → {number:, gateway_id:, total:, due_date:, status:} com status Symbol.
- Produces: Accounts::PurchaseSerializer#installments → [{number:, amount:, due_on:, status:, provider_ref:}], ordenada por number, sem os status intraduzíveis. Accounts::PurchaseSerializer::INSTALLMENT_STATUSES.
- [ ] Step 1: Write the failing test
Em spec/serializers/accounts/purchase_serializer_spec.rb, atualizar a fixture e o payload
esperado, e acrescentar dois exemplos:
```ruby let(:gateway_installments) do [ {number: 2, gateway_id: “pay_2”, total: 600.0, due_date: “2026-10-10”, status: :pending}, {number: 1, gateway_id: “pay_1”, total: 600.0, due_date: “2026-09-10”, status: :paid} ] end
let(:expected_payload) do { external_id: payment.pid, product_slug: “curso-extensao-2026”, customer: { name: “João da Silva”, email: “joao@example.com”, document: customer_document, document_type: “CPF”, phone: “11999999999”, country: “BR” }, payment_method: “BOLETO”, provider: “asaas”, installments: [ {number: 1, amount: 600.0, due_on: “2026-09-10”, status: “PAID”, provider_ref: “pay_1”}, {number: 2, amount: 600.0, due_on: “2026-10-10”, status: “PENDING”, provider_ref: “pay_2”} ] } end ```
```ruby it “translates every status the accounts knows” do installments = [ {number: 1, gateway_id: “pay_1”, total: 1.0, due_date: “2026-09-10”, status: :pending}, {number: 2, gateway_id: “pay_2”, total: 1.0, due_date: “2026-10-10”, status: :paid}, {number: 3, gateway_id: “pay_3”, total: 1.0, due_date: “2026-11-10”, status: :overdue}, {number: 4, gateway_id: “pay_4”, total: 1.0, due_date: “2026-12-10”, status: :refunded} ]
expect(serialize(payment, installments: installments).fetch(:installments).map { |i| i[:status] })
.to eq(["PENDING", "PAID", "OVERDUE", "REFUNDED"]) end
it “drops a charge whose status the accounts cannot read” do installments = [ {number: 1, gateway_id: “pay_1”, total: 600.0, due_date: “2026-09-10”, status: :paid}, {number: 2, gateway_id: “pay_old”, total: 600.0, due_date: “2026-10-10”, status: :deleted_or_canceled_by_new_payment}, {number: 2, gateway_id: “pay_2”, total: 650.0, due_date: “2026-10-20”, status: :pending} ]
expect(serialize(payment, installments: installments).fetch(:installments)).to eq([
{number: 1, amount: 600.0, due_on: "2026-09-10", status: "PAID", provider_ref: "pay_1"},
{number: 2, amount: 650.0, due_on: "2026-10-20", status: "PENDING", provider_ref: "pay_2"}
]) end ```
E, em spec/services/accounts/dispatch_purchase_created_spec.rb, acrescentar "status" às
duas parcelas de expected_envelope e à asserção de #envelope:
ruby
"installments" => [
{"number" => 1, "amount" => 600.0, "due_on" => "2026-09-10", "status" => "PENDING", "provider_ref" => "pay_1"},
{"number" => 2, "amount" => 600.0, "due_on" => "2026-10-10", "status" => "PENDING", "provider_ref" => "pay_2"}
]
ruby
expect(envelope[:payload][:installments]).to eq([
{number: 1, amount: 100.0, due_on: "2026-09-20", status: "PENDING", provider_ref: "pay_solo"}
])
- [ ] Step 2: Run to verify it fails
bash
make run.test path="spec/serializers/accounts/purchase_serializer_spec.rb spec/services/accounts/dispatch_purchase_created_spec.rb"
Expected: FAIL — o payload serializado não tem a chave :status (expected: {... status: "PAID" ...}, got: {... }), e translates every status/drops a charge falham pelo mesmo motivo.
- [ ] Step 3: Write minimal implementation
Em app/serializers/accounts/purchase_serializer.rb, acrescentar a constante junto de
PAYMENT_METHODS e trocar o método installments:
ruby
# The accounts only knows these four; a charge outside them (cancelled by a new
# payment, draft, still being created) is dropped instead of guessed. Dropping is
# safe because the reconcile on the other side never deletes what the payload
# leaves out — and it avoids two charges disputing the same installment number.
INSTALLMENT_STATUSES = {
pending: "PENDING",
paid: "PAID",
overdue: "OVERDUE",
refunded: "REFUNDED"
}.freeze
```ruby # Installments come from the gateway, not from the local Installment records: # they do not exist yet when the webhook that triggers this event arrives. def installments @instance_options.fetch(:installments) .filter_map { |installment| accounts_installment(installment) } .sort_by { |installment| installment[:number] } end
private
def accounts_installment(installment)
status = INSTALLMENT_STATUSES[installment[:status]]
return unless status
{
number: installment[:number],
amount: installment[:total].to_f,
due_on: installment[:due_date].to_s,
status: status,
provider_ref: installment[:gateway_id]
}
end ```
privatefica no fim do arquivo; os demais métodos de atributo (external_id,product_slug,customer,payment_method,provider,installments) continuam públicos, que é o que oActiveModel::Serializerexige.
- [ ] Step 4: Run to verify it passes
bash
make run.test path="spec/serializers/accounts/purchase_serializer_spec.rb spec/services/accounts/dispatch_purchase_created_spec.rb"
Expected: PASS
- [ ] Step 5: Commit
bash
git add app/serializers/accounts/purchase_serializer.rb spec/serializers/accounts/purchase_serializer_spec.rb spec/services/accounts/dispatch_purchase_created_spec.rb
git commit -m "feat: translate installment status for the accounts"
Task 2: Accounts::InstallmentOverdueSerializer
Files:
- Create: app/serializers/accounts/installment_overdue_serializer.rb
- Test: spec/serializers/accounts/installment_overdue_serializer_spec.rb
Interfaces:
- Consumes: Accounts::PurchaseSerializer (todos os atributos) e Accounts::PurchaseSerializer#installments.
- Produces: Accounts::InstallmentOverdueSerializer.new(payment, installments:, overdue_provider_ref:).as_json → o payload completo do INSTALLMENT_OVERDUE.
- [ ] Step 1: Write the failing test
```ruby require “rails_helper”
RSpec.describe Accounts::InstallmentOverdueSerializer do let(:organization) { create(:organization, slug: “citrg”) } let(:product) { create(:product, organization: organization, slug: “curso-extensao-2026”) } let(:checkout) { create(:checkout, organization: organization, product: product) } let(:customer_document) { CPF.generate } let(:customer) { create(:customer, organization: organization, name: “João da Silva”, email: “joao@example.com”, doc_number: customer_document, phone_number: “11999999999”, country: “br”) } let(:payment) do create(:payment, organization: organization, checkout: checkout, customer: customer, billing_type: “BOLETO”, installment_count: 2, total: 1200.0) end let(:gateway_installments) do [ {number: 2, gateway_id: “pay_2”, total: 600.0, due_date: “2026-09-10”, status: :overdue}, {number: 1, gateway_id: “pay_1”, total: 600.0, due_date: “2026-08-10”, status: :paid} ] end
def serialize(overdue_provider_ref: “pay_2”, installments: gateway_installments) described_class.new(payment, installments: installments, overdue_provider_ref: overdue_provider_ref).as_json end
it “serializes the INSTALLMENT_OVERDUE payload” do expect(serialize).to eq( external_id: payment.pid, product_slug: “curso-extensao-2026”, customer: { name: “João da Silva”, email: “joao@example.com”, document: customer_document, document_type: “CPF”, phone: “11999999999”, country: “BR” }, payment_method: “BOLETO”, provider: “asaas”, purchased_at: payment.created_at.iso8601, installments: [ {number: 1, amount: 600.0, due_on: “2026-08-10”, status: “PAID”, provider_ref: “pay_1”}, {number: 2, amount: 600.0, due_on: “2026-09-10”, status: “OVERDUE”, provider_ref: “pay_2”} ], overdue_installment: {number: 2, provider_ref: “pay_2”} ) end
it “dates the purchase when it happened, not when it went overdue” do payment.update!(created_at: Time.zone.parse(“2026-07-15 10:00:00 -03:00”))
expect(serialize.fetch(:purchased_at)).to eq("2026-07-15T10:00:00-03:00") end
it “points at an installment that is inside the installments list” do payload = serialize
expect(payload.fetch(:installments)).to include(hash_including(provider_ref: payload.dig(:overdue_installment, :provider_ref))) end end ```
- [ ] Step 2: Run to verify it fails
bash
make run.test path=spec/serializers/accounts/installment_overdue_serializer_spec.rb
Expected: FAIL — uninitialized constant Accounts::InstallmentOverdueSerializer
- [ ] Step 3: Write minimal implementation
```ruby module Accounts class InstallmentOverdueSerializer < PurchaseSerializer attributes :purchased_at, :overdue_installment
# The purchase this event registers can be months old: letting the accounts
# default purchased_at to now() would corrupt any report by date.
def purchased_at
object.created_at.iso8601
end
# Resolved inside the already translated and filtered list, which is what makes
# "the pointed installment is always in installments" true by construction.
def overdue_installment
overdue = installments.find { |installment| installment[:provider_ref] == overdue_provider_ref }
{number: overdue[:number], provider_ref: overdue[:provider_ref]}
end
private
def overdue_provider_ref
@instance_options.fetch(:overdue_provider_ref)
end end end ```
Se o
ActiveModel::Serializernão herdar os atributos do pai (a versão em uso é a 0.10, onde herda), o primeiro exemplo falha listando sópurchased_ateoverdue_installment— nesse caso, redeclarar os atributos do pai no filho e seguir.
- [ ] Step 4: Run to verify it passes
bash
make run.test path=spec/serializers/accounts/installment_overdue_serializer_spec.rb
Expected: PASS
- [ ] Step 5: Commit
bash
git add app/serializers/accounts/installment_overdue_serializer.rb spec/serializers/accounts/installment_overdue_serializer_spec.rb
git commit -m "feat: add the installment overdue payload serializer"
Task 3: Accounts::DispatchInstallmentOverdue
Files:
- Create: app/services/accounts/dispatch_installment_overdue.rb
- Test: spec/services/accounts/dispatch_installment_overdue_spec.rb
Interfaces:
- Consumes: Accounts::InstallmentOverdueSerializer, Asaas::Installments::FetchFromGateway.call(gateway_service:, payment:), Asaas::Client.new(organization_id), OutboxEvent, Outbox::DispatchEventsJob.
- Produces: Accounts::DispatchInstallmentOverdue.call(payment:, gateway_id:) → OutboxEvent ou nil. EVENT_NAME = "accounts.installment_overdue".
- [ ] Step 1: Write the failing test
```ruby require “rails_helper”
RSpec.describe Accounts::DispatchInstallmentOverdue do let(:organization) { create(:organization, slug: “citrg”) } let(:product) { create(:product, organization: organization, slug: “curso-extensao-2026”) } let(:checkout) { create(:checkout, organization: organization, product: product) } let(:customer) { create(:customer, organization: organization, doc_number: CPF.generate, country: “br”) } let(:gateway_service) { instance_double(Asaas::Client) } let(:payment) do create(:payment, organization: organization, checkout: checkout, customer: customer, reference: “payment_ref”, billing_type: “BOLETO”, installment_count: 2, total: 1200.0, gateway_id: “pay_1”, gateway_installment_id: “2765d086-c7c5-5cca-898a-4262d212587c”) end let(:live_plan) do [ {id: “pay_1”, installmentNumber: 1, value: 600.0, netValue: nil, dueDate: “2026-08-10”, invoiceNumber: nil, paymentDate: “2026-08-09”, status: “RECEIVED”}, {id: “pay_2”, installmentNumber: 2, value: 600.0, netValue: nil, dueDate: “2026-09-10”, invoiceNumber: nil, paymentDate: nil, status: “OVERDUE”} ] end
before do allow(Asaas::Client).to receive(:new).and_return(gateway_service) allow(Outbox::DispatchEventsJob).to receive(:perform_later) allow(gateway_service).to receive(:get_installments).with(“2765d086-c7c5-5cca-898a-4262d212587c”).and_return(data: live_plan) end
# Frozen so occurred_at cannot straddle a second boundary between the call and # the assertion. around { |example| Timecop.freeze { example.run } }
it “creates an OutboxEvent with the envelope and enqueues the dispatch job” do event = described_class.call(payment: payment, gateway_id: “pay_2”)
expect(event).to be_persisted
expect(event.event_name).to eq("accounts.installment_overdue")
expect(event.payload).to eq(
"event_type" => "INSTALLMENT_OVERDUE",
"external_id" => payment.pid,
"occurred_at" => Time.current.iso8601,
"payload" => {
"external_id" => payment.pid,
"product_slug" => "curso-extensao-2026",
"customer" => {
"name" => customer.name,
"email" => customer.email,
"document" => customer.doc_number,
"document_type" => "CPF",
"phone" => customer.phone_number,
"country" => "BR"
},
"payment_method" => "BOLETO",
"provider" => "asaas",
"purchased_at" => payment.created_at.iso8601,
"installments" => [
{"number" => 1, "amount" => 600.0, "due_on" => "2026-08-10", "status" => "PAID", "provider_ref" => "pay_1"},
{"number" => 2, "amount" => 600.0, "due_on" => "2026-09-10", "status" => "OVERDUE", "provider_ref" => "pay_2"}
],
"overdue_installment" => {"number" => 2, "provider_ref" => "pay_2"}
}
)
expect(Outbox::DispatchEventsJob).to have_received(:perform_later).with(event) end
it “is idempotent per installment, not per payment” do create(:outbox_event, event_name: “accounts.installment_overdue”, payload: {“payload” => {“overdue_installment” => {“number” => 2, “provider_ref” => “pay_2”}}})
expect { described_class.call(payment: payment, gateway_id: "pay_2") }.not_to change(OutboxEvent, :count) end
it “dispatches again for a different installment of the same payment” do create(:outbox_event, event_name: “accounts.installment_overdue”, payload: {“payload” => {“overdue_installment” => {“number” => 1, “provider_ref” => “pay_1”}}})
expect { described_class.call(payment: payment, gateway_id: "pay_2") }.to change(OutboxEvent, :count).by(1) end
it “skips silently when the overdue charge is not in the live plan” do allow(Rails.logger).to receive(:error)
expect(described_class.call(payment: payment, gateway_id: "pay_gone")).to be_nil
expect(OutboxEvent.count).to eq(0)
expect(Outbox::DispatchEventsJob).not_to have_received(:perform_later)
expect(Rails.logger).not_to have_received(:error) end
it “skips silently when the overdue charge was filtered out of the plan” do live_plan.last[:status] = “DELETED” allow(Rails.logger).to receive(:error)
expect(described_class.call(payment: payment, gateway_id: "pay_2")).to be_nil
expect(OutboxEvent.count).to eq(0) end
it “dispatches even when the live plan is shorter than installment_count” do allow(gateway_service).to receive(:get_installments).and_return(data: [live_plan.last])
expect { described_class.call(payment: payment, gateway_id: "pay_2") }.to change(OutboxEvent, :count).by(1) end
it “does not raise and logs an error on an unexpected failure” do allow(gateway_service).to receive(:get_installments).and_raise(“gateway timeout”) allow(Rails.logger).to receive(:error)
expect { described_class.call(payment: payment, gateway_id: "pay_2") }.not_to raise_error
expect(OutboxEvent.count).to eq(0)
expect(Rails.logger).to have_received(:error) { |&message| expect(message.call).to include("gateway timeout") } end end ```
- [ ] Step 2: Run to verify it fails
bash
make run.test path=spec/services/accounts/dispatch_installment_overdue_spec.rb
Expected: FAIL — uninitialized constant Accounts::DispatchInstallmentOverdue
- [ ] Step 3: Write minimal implementation
```ruby module Accounts class DispatchInstallmentOverdue EVENT_NAME = “accounts.installment_overdue” EVENT_TYPE = “INSTALLMENT_OVERDUE”
def self.call(payment:, gateway_id:)
new(payment, gateway_id).call
end
def initialize(payment, gateway_id)
@payment = payment
@gateway_id = gateway_id
end
# Skips silently when the overdue charge is not in the live plan — cancelled, or
# not published yet: the next webhook for the same payment tries again. Unlike
# the purchase created event, a plan shorter than installment_count is fine here:
# the accounts reconciles what arrives and never deletes what is missing.
def call
return if already_dispatched?
return unless overdue_in_plan?
event = OutboxEvent.create!(event_name: EVENT_NAME, payload: envelope)
Outbox::DispatchEventsJob.perform_later(event)
event
rescue => e
Rails.logger.error { "[#{EVENT_NAME}] Falha ao montar ou gravar o evento para #{@payment.reference}: #{e.class} - #{e.message}" }
nil
end
def envelope
{
event_type: EVENT_TYPE,
external_id: @payment.pid,
occurred_at: Time.current.iso8601,
payload: serializer.as_json
}
end
private
def serializer
@serializer ||= InstallmentOverdueSerializer.new(
@payment,
installments: gateway_installments,
overdue_provider_ref: @gateway_id
)
end
def overdue_in_plan?
serializer.installments.any? { |installment| installment[:provider_ref] == @gateway_id }
end
def gateway_installments
@gateway_installments ||= Asaas::Installments::FetchFromGateway.call(gateway_service: gateway_service, payment: @payment)
end
def gateway_service
@gateway_service ||= Asaas::Client.new(@payment.checkout.organization_id)
end
# Per installment, not per payment: the same plan can go overdue more than once.
def already_dispatched?
OutboxEvent.where(event_name: EVENT_NAME)
.exists?(["payload -> 'payload' -> 'overdue_installment' ->> 'provider_ref' = ?", @gateway_id])
end end end ```
- [ ] Step 4: Run to verify it passes
bash
make run.test path=spec/services/accounts/dispatch_installment_overdue_spec.rb
Expected: PASS
- [ ] Step 5: Commit
bash
git add app/services/accounts/dispatch_installment_overdue.rb spec/services/accounts/dispatch_installment_overdue_spec.rb
git commit -m "feat: dispatch the installment overdue outbox event"
Task 4: dispatcher do accounts e registro
Independente das tasks 1–3; pode rodar em paralelo.
Files:
- Create: app/services/outbox/dispatchers/accounts_base.rb
- Create: app/services/outbox/dispatchers/accounts_installment_overdue.rb
- Modify: app/services/outbox/dispatchers/accounts_purchase_create.rb
- Modify: app/services/outbox/dispatch_registry.rb
- Test: spec/services/outbox/dispatchers/accounts_installment_overdue_spec.rb
- Test: spec/services/outbox/dispatch_registry_spec.rb
Interfaces:
- Produces: Outbox::Dispatchers::AccountsInstallmentOverdue.call(payload) → HTTParty::Response; entrada "accounts.installment_overdue" no DispatchRegistry::DISPATCHERS.
- [ ] Step 1: Write the failing test
spec/services/outbox/dispatchers/accounts_installment_overdue_spec.rb:
```ruby require “rails_helper”
RSpec.describe Outbox::Dispatchers::AccountsInstallmentOverdue do describe “.call” do let(:payload) { {“event_type” => “INSTALLMENT_OVERDUE”, “external_id” => “payment_abc”} } let(:response) { instance_double(HTTParty::Response, success?: true, code: 201, body: “{}”) }
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("ACCOUNTS_API_URL").and_return("https://accounts.example.com")
allow(ENV).to receive(:[]).with("ACCOUNTS_API_TOKEN").and_return("test_token")
end
it "posts to the accounts events endpoint with the internal-origin headers" do
allow(HTTParty).to receive(:post).and_return(response)
described_class.call(payload)
expect(HTTParty).to have_received(:post).with(
"https://accounts.example.com/api/v1/events",
body: payload.to_json,
headers: {"Content-Type" => "application/json", "X-Origin" => "checkout", "Authorization" => "Bearer test_token"},
timeout: 3
)
end
it "returns the HTTParty response" do
allow(HTTParty).to receive(:post).and_return(response)
expect(described_class.call(payload)).to eq(response)
end
it "raises a clear error when the env vars are missing" do
allow(ENV).to receive(:[]).with("ACCOUNTS_API_URL").and_return(nil)
expect { described_class.call(payload) }.to raise_error(/ACCOUNTS_API_URL/)
end end end ```
Em spec/services/outbox/dispatch_registry_spec.rb, acrescentar:
ruby
it "resolves the accounts installment overdue event to its dispatcher" do
expect(described_class.resolve("accounts.installment_overdue")).to eq(Outbox::Dispatchers::AccountsInstallmentOverdue)
end
- [ ] Step 2: Run to verify it fails
bash
make run.test path="spec/services/outbox/dispatchers/accounts_installment_overdue_spec.rb spec/services/outbox/dispatch_registry_spec.rb"
Expected: FAIL — uninitialized constant Outbox::Dispatchers::AccountsInstallmentOverdue
- [ ] Step 3: Write minimal implementation
app/services/outbox/dispatchers/accounts_base.rb (é o corpo atual do
AccountsPurchaseCreate, movido — os dois eventos do accounts postam no mesmo endpoint,
com os mesmos headers):
```ruby module Outbox module Dispatchers class AccountsBase ENDPOINT_PATH = “/api/v1/events” REQUEST_TIMEOUT = 3
def self.call(payload)
new.call(payload)
end
def call(payload)
raise "ACCOUNTS_API_URL ou ACCOUNTS_API_TOKEN não configurados" unless configured?
HTTParty.post(
"#{ENV["ACCOUNTS_API_URL"]}#{ENDPOINT_PATH}",
body: payload.to_json,
headers: {
"Content-Type" => "application/json",
"X-Origin" => "checkout",
"Authorization" => "Bearer #{ENV["ACCOUNTS_API_TOKEN"]}"
},
timeout: REQUEST_TIMEOUT
)
end
private
def configured?
ENV["ACCOUNTS_API_URL"].present? && ENV["ACCOUNTS_API_TOKEN"].present?
end
end end end ```
app/services/outbox/dispatchers/accounts_purchase_create.rb (substitui o conteúdo inteiro):
ruby
module Outbox
module Dispatchers
# Exists as a distinct class so the DispatchRegistry has one target per event_name.
class AccountsPurchaseCreate < AccountsBase
end
end
end
app/services/outbox/dispatchers/accounts_installment_overdue.rb:
ruby
module Outbox
module Dispatchers
class AccountsInstallmentOverdue < AccountsBase
end
end
end
app/services/outbox/dispatch_registry.rb:
ruby
DISPATCHERS = {
"new_checkout.repayment_sync" => Outbox::Dispatchers::NewCheckoutRepaymentSync,
"new_checkout.customer_sync" => Outbox::Dispatchers::NewCheckoutCustomerSync,
"accounts.purchase_create" => Outbox::Dispatchers::AccountsPurchaseCreate,
"accounts.installment_overdue" => Outbox::Dispatchers::AccountsInstallmentOverdue
}.freeze
- [ ] Step 4: Run to verify it passes
bash
make run.test path="spec/services/outbox"
Expected: PASS — inclusive accounts_purchase_create_spec.rb, que continua verde sem mudança (é o mesmo comportamento, agora herdado).
- [ ] Step 5: Commit
bash
git add app/services/outbox spec/services/outbox
git commit -m "feat: add the accounts installment overdue dispatcher"
Task 5: step novo no PaymentsFlow
Files:
- Create: app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event.rb
- Modify: app/use_cases/incoming_webhooks/payment_gateways/asaas/payments/payments_flow.rb
- Test: spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb
Interfaces:
- Consumes: Accounts::DispatchInstallmentOverdue.call(payment:, gateway_id:) (Task 3); contexto do flow: payment, installment_params, should_update_payment, gateway_id.
- [ ] Step 1: Write the failing test
```ruby require “rails_helper”
module IncomingWebhooks::PaymentGateways::Asaas::Payments describe DispatchInstallmentOverdueEvent do describe “#call” do let(:payment) { create(:payment, kind: “standard”) }
def run(payment:, status:, should_update_payment: true)
described_class.call(
payment: payment,
installment_params: {status: status},
should_update_payment: should_update_payment,
gateway_id: "pay_2"
)
end
before { allow(Accounts::DispatchInstallmentOverdue).to receive(:call) }
it "dispatches when the webhook takes the installment to overdue" do
run(payment: payment, status: :overdue)
expect(Accounts::DispatchInstallmentOverdue).to have_received(:call).with(payment: payment, gateway_id: "pay_2")
end
it "does not dispatch for any other installment status" do
run(payment: payment, status: :paid)
expect(Accounts::DispatchInstallmentOverdue).not_to have_received(:call)
end
it "does not dispatch when the webhook does not update the payment" do
run(payment: payment, status: :overdue, should_update_payment: false)
expect(Accounts::DispatchInstallmentOverdue).not_to have_received(:call)
end
it "does not dispatch for a repayment" do
run(payment: create(:payment, kind: "repayment"), status: :overdue)
expect(Accounts::DispatchInstallmentOverdue).not_to have_received(:call)
end
end end end ```
- [ ] Step 2: Run to verify it fails
bash
make run.test path=spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb
Expected: FAIL — uninitialized constant IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchInstallmentOverdueEvent
- [ ] Step 3: Write minimal implementation
dispatch_installment_overdue_event.rb:
```ruby module IncomingWebhooks::PaymentGateways::Asaas::Payments class DispatchInstallmentOverdueEvent < Ucasy::Base required_attributes(:payment, :installment_params, :should_update_payment, :gateway_id)
def call
return unless should_dispatch?
Accounts::DispatchInstallmentOverdue.call(payment: context.payment, gateway_id: context.gateway_id)
end
private
def should_dispatch?
context.should_update_payment &&
context.installment_params[:status] == :overdue &&
context.payment.kind == "standard"
end end end ```
payments_flow.rb — acrescentar o step no fim do flow(...):
ruby
IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchPurchaseCreatedEvent,
IncomingWebhooks::PaymentGateways::Asaas::Payments::DispatchInstallmentOverdueEvent
- [ ] Step 4: Run to verify it passes
bash
make run.test path=spec/use_cases/incoming_webhooks/payment_gateways/asaas
Expected: PASS
- [ ] Step 5: Commit
bash
git add app/use_cases/incoming_webhooks/payment_gateways/asaas/payments spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments
git commit -m "feat: dispatch the overdue event from the asaas webhook"
Task 6: documentação
Sem ciclo TDD — é a R-005 nova, a referência do contrato e as atualizações de índice que a
spec pede.
Files:
- Create: .project/docs/reference/payments/accounts_installment_overdue_event.md
- Create: .project/docs/rules/payments/accounts_installment_overdue_dispatch.md
- Modify: .project/docs/reference/payments/accounts_purchase_created_event.md
- Modify: .project/docs/rules/payments/outbox_event_dispatch.md
- Modify: .project/docs/RULES.md
- Modify: .project/docs/README.md
- [ ] Step 1: Escrever a referência do contrato
accounts_installment_overdue_event.md, espelhando accounts_purchase_created_event.md:
envelope completo em JSON, a afirmação de que ele é o payload do PURCHASE_CREATED mais
purchased_at, status por parcela e overdue_installment, o INSTALLMENT_STATUSES com a
nota de que cobrança fora dos quatro não entra, e o lembrete de que as parcelas vêm de busca
ao vivo no Asaas, nunca do Installment local. Frontmatter com certainty: high.
- [ ] Step 2: Escrever a
R-005
accounts_installment_overdue_dispatch.md, com id: R-005, scope: payments,
certainty: high, no formato Given/When/Then + tabela de decisão + restrições das outras
regras de rules/payments/. Cobrir:
| Situação | Ação |
|---|---|
webhook leva a parcela para overdue, kind standard |
grava o OutboxEvent |
kind diferente de standard |
não dispara |
status diferente de overdue, ou should_update_payment falso |
não dispara |
| cobrança vencida fora do plano buscado ao vivo | não dispara, em silêncio |
já existe evento com o mesmo overdue_installment.provider_ref |
não dispara |
plano ao vivo menor que installment_count |
dispara mesmo assim |
Teste vinculado: spec/services/accounts/dispatch_installment_overdue_spec.rb e
spec/use_cases/incoming_webhooks/payment_gateways/asaas/payments/dispatch_installment_overdue_event_spec.rb.
-
[ ] Step 3: Atualizar os documentos existentes
accounts_purchase_created_event.md: a parcela passa a levarstatus, e cobrança com status intraduzível deixa de entrar na lista.outbox_event_dispatch.md(R-004): citaraccounts.installment_overduecomo terceiro consumidor da tabela genérica.RULES.md: linha nova doR-005.-
README.md: índice dos dois documentos novos. - [ ] Step 4: Verificar
bash
make check
Expected: PASS (exit 0), igual ao baseline.
- [ ] Step 5: Commit
bash
git add .project/docs
git commit -m "docs: rule and contract for the overdue event"
Verificação final
bash
make check
Depois, ponta a ponta contra o accounts da branch feat/checkout-installment-overdue
(PR #27), nos dois ramos do purchases.Sync — compra que o accounts já conhece e compra
que ele não conhece — conforme a seção Como verificar da spec.
Cobertura da spec
| Requisito da spec | Task |
|---|---|
Tradução e filtro de status no payload compartilhado |
1 |
PURCHASE_CREATED passa a mandar status |
1 |
Payload do atraso herda o do PURCHASE_CREATED |
2 |
purchased_at = payment.created_at |
2 |
overdue_installment sempre dentro de installments |
2, 3 |
occurred_at no momento do atraso |
3 |
Idempotência por provider_ref |
3 |
| Silêncio quando a cobrança vencida não está no plano | 3 |
| Sem exigência de carnê completo | 3 |
| Falha logada, webhook nunca cai | 3 |
AccountsBase + dispatcher novo + registro |
4 |
Gatilho: overdue + should_update_payment + standard |
5 |
Step no PaymentsFlow |
5 |
Referência do contrato, R-005, R-004, índices |
6 |