Evento de parcela atrasada para o accounts — Plano de implementação

TLDR: seis tasks TDD que fazem o webhook de PAYMENT_OVERDUE do Asaas gravar um OutboxEvent accounts.installment_overdue com o pagamento inteiro, despachado para o POST /api/v1/events do accounts.

Spec: .project/docs/specs/20260922093318_accounts_installment_overdue_event.md Branch: 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 check em 7033256 passa (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 check roda 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 ```

private fica 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 o ActiveModel::Serializer exige.

  • [ ] 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::Serializer não herdar os atributos do pai (a versão em uso é a 0.10, onde herda), o primeiro exemplo falha listando só purchased_at e overdue_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 levar status, e cobrança com status intraduzível deixa de entrar na lista.
  • outbox_event_dispatch.md (R-004): citar accounts.installment_overdue como terceiro consumidor da tabela genérica.
  • RULES.md: linha nova do R-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