IncomingEvent — caixa de entrada de eventos do accounts — Plano de implementação

TLDR: constrói a tabela incoming_events, a ingestão com dedup, o job com 3 tentativas e o endpoint autenticado que recebe os webhooks do accounts.

Spec: .project/docs/specs/20260924094344_incoming_events_inbox.md Branch: feat/incoming-events

Arquitetura: o controller só autentica e repassa o corpo cru ao IncomingEvents::Create, que grava a linha e enfileira o ProcessIncomingEventJob na mesma transação. O job chama IncomingEvents::Process (registry event_type → use case) numa transação própria. A falha é registrada fora dela, e o próprio job se reenfileira em 5s enquanto o evento seguir pending.

Stack: Rails 8.1, u-case (Micro::Case), enumerize, Solid Queue (ActiveJob), Minitest.

Restrições globais

  • Todos os caminhos são relativos a modules/backend/.
  • Comandos de teste rodam localmente contra o Postgres do compose. Antes de qualquer comando: export DATABASE_HOST=localhost DATABASE_PORT=5012 DATABASE_USERNAME=postgres DATABASE_PASSWORD=postgres.
  • Código, nomes e testes em inglês; mensagens de erro para o usuário em português, como no resto da API.
  • Commits em uma linha, até 60 caracteres, sem menção a IA.
  • As tasks são sequenciais: cada uma consome a anterior.

Task 1: Tabela e modelo IncomingEvent

Files: - Create: db/migrate/20260924100100_create_incoming_events.rb - Modify: db/schema.rb (gerado pela migration) - Create: app/models/incoming_event.rb - Test: test/models/incoming_event_test.rb

Interfaces: - Produces: IncomingEvent com MAX_ATTEMPTS = 3, #mark_processed!, #register_failure!(message), predicados pending?/processed?/failed?.

  • [ ] Step 1: Write the failing test

```ruby require “test_helper”

class IncomingEventTest < ActiveSupport::TestCase def build_event(attrs = {}) IncomingEvent.create!({ event_id: SecureRandom.uuid, event_type: “INSTALLMENT_OVERDUE”, occurred_at: Time.current, payload: { “event_id” => “x”, “data” => {} } }.merge(attrs)) end

test “starts pending with no attempts” do event = build_event

assert_predicate event, :pending?
assert_equal 0, event.attempts   end

test “requires event_id, event_type, occurred_at and payload” do event = IncomingEvent.new

assert_not event.valid?
assert_includes event.errors.attribute_names, :event_id
assert_includes event.errors.attribute_names, :event_type
assert_includes event.errors.attribute_names, :occurred_at
assert_includes event.errors.attribute_names, :payload   end

test “event_id is unique at the database level” do event = build_event

assert_raises(ActiveRecord::RecordNotUnique) { build_event(event_id: event.event_id) }   end

test “mark_processed! sets processed and processed_at” do event = build_event

event.mark_processed!

assert_predicate event.reload, :processed?
assert_not_nil event.processed_at   end

test “register_failure! below the limit keeps the event pending” do event = build_event

event.register_failure!("boom")

event.reload
assert_predicate event, :pending?
assert_equal 1, event.attempts
assert_equal "boom", event.error_message
assert_nil event.processed_at   end

test “register_failure! on the last attempt fails the event” do event = build_event(attempts: IncomingEvent::MAX_ATTEMPTS - 1)

event.register_failure!("boom again")

event.reload
assert_predicate event, :failed?
assert_equal IncomingEvent::MAX_ATTEMPTS, event.attempts
assert_equal "boom again", event.error_message
assert_not_nil event.processed_at   end end ```
  • [ ] Step 2: Run to verify it fails

bash bin/rails test test/models/incoming_event_test.rb

Expected: FAIL — NameError: uninitialized constant IncomingEvent

  • [ ] Step 3: Write minimal implementation

db/migrate/20260924100100_create_incoming_events.rb:

```ruby class CreateIncomingEvents < ActiveRecord::Migration[8.1] def change create_table :incoming_events do |t| t.string :event_id, null: false t.string :event_type, null: false t.datetime :occurred_at, null: false t.jsonb :payload, null: false t.string :status, null: false, default: “pending” t.integer :attempts, null: false, default: 0 t.text :error_message t.datetime :processed_at

  t.timestamps
end

add_index :incoming_events, :event_id, unique: true
add_index :incoming_events, [ :status, :created_at ]   end end ```

bash bin/rails db:migrate

Confirmar que db/schema.rb ganhou só a tabela incoming_events. O annotaterb preenche o cabeçalho do modelo no próximo db:migrate; se não preencher, rodar bundle exec annotaterb models.

app/models/incoming_event.rb:

```ruby class IncomingEvent < ApplicationRecord extend Enumerize

MAX_ATTEMPTS = 3

enumerize :status, in: %i[pending processed failed], default: :pending, predicates: true, scope: true

validates :event_id, :event_type, :occurred_at, :payload, presence: true

def mark_processed! update!(status: :processed, processed_at: Time.current) end

def register_failure!(message) self.attempts += 1 self.error_message = message if attempts >= MAX_ATTEMPTS self.status = :failed self.processed_at = Time.current end save! end end ```

  • [ ] Step 4: Run to verify it passes

bash bin/rails test test/models/incoming_event_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add db/migrate/20260924100100_create_incoming_events.rb db/schema.rb app/models/incoming_event.rb test/models/incoming_event_test.rb git commit -m "feat: add IncomingEvent model"


Task 2: Registry IncomingEvents::Process

Files: - Create: app/use_cases/incoming_events/process.rb - Test: test/use_cases/incoming_events/process_test.rb

Interfaces: - Consumes: IncomingEvent (Task 1). - Produces: IncomingEvents::Process.call(incoming_event:) → Success(result: <dados do use case>) ou Failure(type, result: { message: String }). IncomingEvents::Process.use_cases → Hash{String => Class}, vazio nesta entrega.

  • [ ] Step 1: Write the failing test

```ruby require “test_helper”

module IncomingEvents class ProcessTest < ActiveSupport::TestCase class EchoUseCase < Micro::Case attribute :data

  def call!
    Success(result: { received: data })
  end
end

class RejectingUseCase < Micro::Case
  attribute :data

  def call!
    Failure(:rejected, result: { message: "rejected by domain" })
  end
end

def incoming_event(event_type: "INSTALLMENT_OVERDUE")
  IncomingEvent.new(
    event_id: SecureRandom.uuid, event_type: event_type, occurred_at: Time.current,
    payload: { "event_type" => event_type, "data" => { "customer_email" => "a@b.com" } }
  )
end

test "fails with no_use_case when the event_type is not registered" do
  result = IncomingEvents::Process.call(incoming_event: incoming_event)

  assert_predicate result, :failure?
  assert_equal :no_use_case, result.type
  assert_equal "no use case registered for event: INSTALLMENT_OVERDUE", result[:message]
end

test "passes the envelope data to the registered use case" do
  IncomingEvents::Process.stub(:use_cases, { "INSTALLMENT_OVERDUE" => EchoUseCase }) do
    result = IncomingEvents::Process.call(incoming_event: incoming_event)

    assert_predicate result, :success?
    assert_equal({ "customer_email" => "a@b.com" }, result[:received])
  end
end

test "propagates the failure of the registered use case" do
  IncomingEvents::Process.stub(:use_cases, { "INSTALLMENT_OVERDUE" => RejectingUseCase }) do
    result = IncomingEvents::Process.call(incoming_event: incoming_event)

    assert_predicate result, :failure?
    assert_equal :rejected, result.type
    assert_equal "rejected by domain", result[:message]
  end
end

test "registry starts empty" do
  assert_empty IncomingEvents::Process.use_cases
end   end end ```
  • [ ] Step 2: Run to verify it fails

bash bin/rails test test/use_cases/incoming_events/process_test.rb

Expected: FAIL — NameError: uninitialized constant IncomingEvents::Process

  • [ ] Step 3: Write minimal implementation

```ruby module IncomingEvents class Process < Micro::Case USE_CASES = {}.freeze

attribute :incoming_event, validates: { presence: true }

def self.use_cases = USE_CASES

def call!
  use_case = self.class.use_cases[incoming_event.event_type]
  return no_use_case_failure unless use_case

  result = use_case.call(data: incoming_event.payload["data"])
  result.success? ? Success(result: result.data) : Failure(result.type, result: result.data)
end

private

def no_use_case_failure
  Failure(:no_use_case, result: { message: "no use case registered for event: #{incoming_event.event_type}" })
end   end end ```
  • [ ] Step 4: Run to verify it passes

bash bin/rails test test/use_cases/incoming_events/process_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add app/use_cases/incoming_events/process.rb test/use_cases/incoming_events/process_test.rb git commit -m "feat: add IncomingEvents::Process registry"


Task 3: ProcessIncomingEventJob com tentativas

Files: - Create: app/jobs/process_incoming_event_job.rb - Test: test/jobs/process_incoming_event_job_test.rb

Interfaces: - Consumes: IncomingEvent#mark_processed!, #register_failure!, #pending? (Task 1); IncomingEvents::Process.call(incoming_event:) (Task 2). - Produces: ProcessIncomingEventJob.perform_later(incoming_event_id); ProcessIncomingEventJob::RETRY_DELAY = 5.seconds.

  • [ ] Step 1: Write the failing test

```ruby require “test_helper”

class ProcessIncomingEventJobTest < ActiveJob::TestCase class SucceedingUseCase < Micro::Case attribute :incoming_event

def call! = Success(result: {})   end

class FailingUseCase < Micro::Case attribute :incoming_event

def call! = Failure(:boom, result: { message: "boom" })   end

class WritesThenFailsUseCase < Micro::Case attribute :incoming_event

def call!
  Customer.create!(name: "Rollback", email: "rollback@example.com", phone: "11999999999")
  Failure(:boom, result: { message: "boom" })
end   end

def create_event(attrs = {}) IncomingEvent.create!({ event_id: SecureRandom.uuid, event_type: “INSTALLMENT_OVERDUE”, occurred_at: Time.current, payload: { “data” => {} } }.merge(attrs)) end

def with_process(use_case, &block) IncomingEvents::Process.stub(:call, ->(incoming_event:) { use_case.call(incoming_event: incoming_event) }, &block) end

test “marks the event processed when the use case succeeds” do event = create_event

with_process(SucceedingUseCase) { ProcessIncomingEventJob.perform_now(event.id) }

assert_predicate event.reload, :processed?
assert_no_enqueued_jobs only: ProcessIncomingEventJob   end

test “counts the failure and retries in 5 seconds while attempts remain” do freeze_time do event = create_event

  with_process(FailingUseCase) { ProcessIncomingEventJob.perform_now(event.id) }

  event.reload
  assert_predicate event, :pending?
  assert_equal 1, event.attempts
  assert_equal "boom", event.error_message
  assert_enqueued_with(job: ProcessIncomingEventJob, args: [ event.id ], at: ProcessIncomingEventJob::RETRY_DELAY.from_now)
end   end

test “fails the event on the third failure and stops retrying” do event = create_event(attempts: 2)

with_process(FailingUseCase) { ProcessIncomingEventJob.perform_now(event.id) }

event.reload
assert_predicate event, :failed?
assert_equal 3, event.attempts
assert_no_enqueued_jobs only: ProcessIncomingEventJob   end

test “an exception counts as a failure” do event = create_event

IncomingEvents::Process.stub(:call, ->(**) { raise ArgumentError, "bad data" }) do
  ProcessIncomingEventJob.perform_now(event.id)
end

event.reload
assert_equal 1, event.attempts
assert_equal "ArgumentError: bad data", event.error_message   end

test “without a registered use case the event ends failed after three attempts” do event = create_event

ProcessIncomingEventJob.perform_now(event.id)
ProcessIncomingEventJob.perform_now(event.id)
ProcessIncomingEventJob.perform_now(event.id)

event.reload
assert_predicate event, :failed?
assert_equal "no use case registered for event: INSTALLMENT_OVERDUE", event.error_message   end

test “rolls back the use case writes but keeps the attempt” do event = create_event

with_process(WritesThenFailsUseCase) { ProcessIncomingEventJob.perform_now(event.id) }

assert_not Customer.exists?(email: "rollback@example.com")
assert_equal 1, event.reload.attempts   end

test “does nothing when the event is no longer pending” do event = create_event(status: :processed)

with_process(FailingUseCase) { ProcessIncomingEventJob.perform_now(event.id) }

assert_equal 0, event.reload.attempts   end end ```
  • [ ] Step 2: Run to verify it fails

bash bin/rails test test/jobs/process_incoming_event_job_test.rb

Expected: FAIL — NameError: uninitialized constant ProcessIncomingEventJob

  • [ ] Step 3: Write minimal implementation

```ruby class ProcessIncomingEventJob < ApplicationJob RETRY_DELAY = 5.seconds

class ProcessingFailed < StandardError; end

queue_as :default self.enqueue_after_transaction_commit = false

def perform(incoming_event_id) incoming_event = IncomingEvent.find(incoming_event_id) return unless incoming_event.pending?

failure_message = process(incoming_event)
return if failure_message.nil?

incoming_event.register_failure!(failure_message)
self.class.set(wait: RETRY_DELAY).perform_later(incoming_event.id) if incoming_event.pending?   end

private

def process(incoming_event) ActiveRecord::Base.transaction(requires_new: true) do result = IncomingEvents::Process.call(incoming_event: incoming_event) raise ProcessingFailed, (result[:message] || result.type.to_s) if result.failure?

  incoming_event.mark_processed!
end
nil   rescue ProcessingFailed => e
e.message   rescue StandardError => e
"#{e.class}: #{e.message}"   end end ```

requires_new: true abre um savepoint: nos testes transacionais é o que de fato reverte as escritas do use case, e em produção não muda nada, porque o job já roda fora de transação. O register_failure! fica fora do bloco para a tentativa sobreviver ao rollback.

  • [ ] Step 4: Run to verify it passes

bash bin/rails test test/jobs/process_incoming_event_job_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add app/jobs/process_incoming_event_job.rb test/jobs/process_incoming_event_job_test.rb git commit -m "feat: add ProcessIncomingEventJob with retries"


Task 4: Ingestão IncomingEvents::Create

Files: - Create: app/use_cases/incoming_events/create.rb - Test: test/use_cases/incoming_events/create_test.rb

Interfaces: - Consumes: IncomingEvent (Task 1); ProcessIncomingEventJob.perform_later(id) (Task 3). - Produces: IncomingEvents::Create.call(envelope: Hash) → Success(result: { incoming_event:, duplicate: false }), Success(result: { duplicate: true }) ou Failure(:invalid_payload).

  • [ ] Step 1: Write the failing test

```ruby require “test_helper”

module IncomingEvents class CreateTest < ActiveSupport::TestCase include ActiveJob::TestHelper

def envelope(overrides = {})
  {
    "event_id" => SecureRandom.uuid,
    "event_type" => "INSTALLMENT_OVERDUE",
    "occurred_at" => "2026-09-24T10:05:00+00:00",
    "data" => { "customer_email" => "joana@example.com", "purchase" => { "external_id" => "HP123" } }
  }.merge(overrides)
end

test "stores the whole envelope and enqueues its processing" do
  body = envelope

  result = IncomingEvents::Create.call(envelope: body)

  assert_predicate result, :success?
  assert_equal false, result[:duplicate]
  event = result[:incoming_event]
  assert_predicate event, :persisted?
  assert_equal body["event_id"], event.event_id
  assert_equal "INSTALLMENT_OVERDUE", event.event_type
  assert_equal Time.zone.parse("2026-09-24T10:05:00+00:00"), event.occurred_at
  assert_equal body, event.payload
  assert_enqueued_with(job: ProcessIncomingEventJob, args: [ event.id ])
end

test "a repeated event_id is a duplicate: nothing stored, nothing enqueued" do
  body = envelope
  IncomingEvents::Create.call(envelope: body)
  clear_enqueued_jobs

  result = IncomingEvents::Create.call(envelope: body)

  assert_predicate result, :success?
  assert_equal true, result[:duplicate]
  assert_equal 1, IncomingEvent.where(event_id: body["event_id"]).count
  assert_no_enqueued_jobs only: ProcessIncomingEventJob
end

test "a concurrent insert of the same event_id is treated as a duplicate" do
  body = envelope

  IncomingEvent.stub(:exists?, false) do
    IncomingEvents::Create.call(envelope: body)
    result = IncomingEvents::Create.call(envelope: body)

    assert_predicate result, :success?
    assert_equal true, result[:duplicate]
  end
end

%w[event_id event_type occurred_at data].each do |key|
  test "fails when #{key} is missing" do
    result = IncomingEvents::Create.call(envelope: envelope.except(key))

    assert_predicate result, :failure?
    assert_equal :invalid_payload, result.type
    assert_equal 0, IncomingEvent.count
  end
end

test "fails when occurred_at is not a date" do
  result = IncomingEvents::Create.call(envelope: envelope("occurred_at" => "yesterday-ish"))

  assert_predicate result, :failure?
  assert_equal :invalid_payload, result.type
end

test "fails when the envelope is not an object" do
  result = IncomingEvents::Create.call(envelope: [ "not", "a", "hash" ])

  assert_predicate result, :failure?
  assert_equal :invalid_payload, result.type
end   end end ```
  • [ ] Step 2: Run to verify it fails

bash bin/rails test test/use_cases/incoming_events/create_test.rb

Expected: FAIL — NameError: uninitialized constant IncomingEvents::Create

  • [ ] Step 3: Write minimal implementation

```ruby module IncomingEvents class Create < Micro::Case REQUIRED_KEYS = %w[event_id event_type occurred_at data].freeze

attribute :envelope

def call!
  return Failure(:invalid_payload) unless valid_envelope?
  return duplicate if IncomingEvent.exists?(event_id: envelope["event_id"])

  Success(result: { incoming_event: persist_and_enqueue, duplicate: false })
rescue ActiveRecord::RecordNotUnique
  duplicate
rescue ActiveRecord::RecordInvalid
  Failure(:invalid_payload)
end

private

def valid_envelope?
  envelope.is_a?(Hash) && REQUIRED_KEYS.all? { |key| envelope[key].present? }
end

def duplicate
  Success(result: { duplicate: true })
end

# Solid Queue writes the job to the same database, so the row and its job commit together.
def persist_and_enqueue
  ActiveRecord::Base.transaction do
    incoming_event = IncomingEvent.create!(
      event_id: envelope["event_id"],
      event_type: envelope["event_type"],
      occurred_at: envelope["occurred_at"],
      payload: envelope
    )
    ProcessIncomingEventJob.perform_later(incoming_event.id)
    incoming_event
  end
end   end end ```

occurred_at que não é data vira nil na atribuição, a validação de presença barra e o RecordInvalid vira :invalid_payload.

  • [ ] Step 4: Run to verify it passes

bash bin/rails test test/use_cases/incoming_events/create_test.rb

Expected: PASS

  • [ ] Step 5: Commit

bash git add app/use_cases/incoming_events/create.rb test/use_cases/incoming_events/create_test.rb git commit -m "feat: add IncomingEvents::Create with dedup"


Task 5: Endpoint POST /api/v1/incoming_events

Files: - Create: app/controllers/api/v1/incoming_events_controller.rb - Modify: config/routes.rb - Modify: .env.test - Modify: ../../.env.example - Test: test/controllers/api/v1/incoming_events_controller_test.rb

Interfaces: - Consumes: IncomingEvents::Create.call(envelope:) (Task 4). - Produces: POST /api/v1/incoming_events, autenticado por Authorization: Bearer $ACCOUNTS_WEBHOOK_TOKEN.

  • [ ] Step 1: Write the failing test

```ruby require “test_helper”

module Api module V1 class IncomingEventsControllerTest < ActionDispatch::IntegrationTest include ActiveJob::TestHelper

  ENDPOINT = "/api/v1/incoming_events".freeze

  def envelope(overrides = {})
    {
      event_id: SecureRandom.uuid,
      event_type: "INSTALLMENT_OVERDUE",
      occurred_at: "2026-09-24T10:05:00+00:00",
      data: { customer_email: "joana@example.com" }
    }.merge(overrides)
  end

  def post_event(body, token: ENV.fetch("ACCOUNTS_WEBHOOK_TOKEN"))
    headers = { "Content-Type" => "application/json" }
    headers["Authorization"] = "Bearer #{token}" if token
    post ENDPOINT, params: body.to_json, headers: headers
  end

  test "accepts a new event with 202 and stores it as it arrived" do
    body = envelope

    post_event(body)

    assert_response :accepted
    event = IncomingEvent.find_by!(event_id: body[:event_id])
    assert_equal body.deep_stringify_keys, event.payload
    assert_enqueued_with(job: ProcessIncomingEventJob, args: [ event.id ])
  end

  test "answers 200 to a repeated event_id without storing it again" do
    body = envelope
    post_event(body)

    post_event(body)

    assert_response :ok
    assert_equal 1, IncomingEvent.where(event_id: body[:event_id]).count
  end

  test "rejects a request without token" do
    post_event(envelope, token: nil)

    assert_response :unauthorized
    assert_equal 0, IncomingEvent.count
  end

  test "rejects a request with a wrong token" do
    post_event(envelope, token: "wrong-token")

    assert_response :unauthorized
  end

  test "a user JWT does not authenticate the webhook" do
    post_event(envelope, token: Warden::JWTAuth::UserEncoder.new.call(users(:admin), :user, nil).first)

    assert_response :unauthorized
  end

  test "rejects an incomplete envelope with 422" do
    post_event(envelope.except(:event_type))

    assert_response :unprocessable_entity
    assert_equal "invalid_payload", JSON.parse(response.body).dig("error", "code")
  end

  test "rejects a body that is not JSON with 422" do
    post ENDPOINT, params: "not json", headers: {
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{ENV.fetch('ACCOUNTS_WEBHOOK_TOKEN')}"
    }

    assert_response :unprocessable_entity
  end
end   end end ```
  • [ ] Step 2: Run to verify it fails

bash bin/rails test test/controllers/api/v1/incoming_events_controller_test.rb

Expected: FAIL — KeyError: key not found: "ACCOUNTS_WEBHOOK_TOKEN"

  • [ ] Step 3: Write minimal implementation

.env.test — acrescentar:

ACCOUNTS_WEBHOOK_TOKEN=test-accounts-webhook-token

../../.env.example — acrescentar um bloco no fim:

# ───────────────────────────────────────────── # Webhooks do accounts # Mesmo valor do `token` da WebhookSubscription do nectar-charges no admin do accounts. # Em staging/production é um secret do k8s. # ───────────────────────────────────────────── ACCOUNTS_WEBHOOK_TOKEN=

config/routes.rb — dentro de namespace :v1, depois de resources :contracts:

ruby resources :incoming_events, only: [ :create ]

app/controllers/api/v1/incoming_events_controller.rb:

```ruby module Api module V1 class IncomingEventsController < BaseController skip_before_action :authenticate_request! before_action :authenticate_webhook!

  def create
    IncomingEvents::Create
      .call(envelope: envelope)
      .on_failure { |failure| render_error(:unprocessable_entity, failure.type, "Envelope de evento inválido") }
      .on_success { |success| head(success[:duplicate] ? :ok : :accepted) }
  end

  private

  def authenticate_webhook!
    return if webhook_token.present? &&
              ActiveSupport::SecurityUtils.secure_compare(webhook_token, ENV.fetch("ACCOUNTS_WEBHOOK_TOKEN"))

    render_error(:unauthorized, "unauthorized", "Autenticação necessária")
  end

  def webhook_token
    request.authorization.to_s[/\ABearer (.+)\z/, 1]
  end

  # The raw body is the envelope exactly as the accounts sent it; params would be wrapped and filtered.
  def envelope
    JSON.parse(request.raw_post)
  rescue JSON::ParserError
    nil
  end
end   end end ```
  • [ ] Step 4: Run to verify it passes

bash bin/rails test test/controllers/api/v1/incoming_events_controller_test.rb

Expected: PASS

  • [ ] Step 5: Run the full suite and lint

bash bin/rails test bundle exec rubocop

Expected: PASS, sem ofensas novas.

  • [ ] Step 6: Commit

bash git add app/controllers/api/v1/incoming_events_controller.rb config/routes.rb .env.test ../../.env.example test/controllers/api/v1/incoming_events_controller_test.rb git commit -m "feat: add incoming events webhook endpoint"


Task 6: Regra de negócio e índice de docs

Files: - Create: ../../.project/docs/rules/events/incoming_event_processing.md - Modify: ../../.project/docs/README.md - Modify: ../../.project/docs/RULES.md - Modify: ../../.project/docs/specs/20260924094344_incoming_events_inbox.md (status: done)

Interfaces: - Consumes: o comportamento entregue nas Tasks 1–5.

  • [ ] Step 1: Escrever a regra R-008

.project/docs/rules/events/incoming_event_processing.md, no formato das outras regras (frontmatter id: R-008, scope: events, certainty: high), com:

  • TLDR: todo evento do accounts é gravado em incoming_events antes do 2xx, deduplicado por event_id e processado por um job com 3 tentativas; failed é terminal.
  • Given/When/Then da ingestão: evento novo (202, grava e enfileira na mesma transação), event_id repetido (200, nada muda), token inválido (401), envelope incompleto (422).
  • A tabela de decisão do processamento, igual à da spec.
  • Restrições: sem distinção entre erro retryable e definitivo; evento sem use case termina failed; reprocesso só pelo console, com o snippet da spec; sem alerta automático.
  • Teste vinculado: os cinco arquivos de teste deste plano.
  • Referências: a spec e a R-001 do synapse no accounts.

  • [ ] Step 2: Atualizar os índices

Em .project/docs/README.md, na tabela de rules/:

| R-008 | [incoming_event_processing](rules/events/incoming_event_processing.md) | events |

Na tabela de plans/:

| [20260924100043_incoming_events_inbox.md](plans/20260924100043_incoming_events_inbox.md) | [IncomingEvent — caixa de entrada de eventos do accounts](specs/20260924094344_incoming_events_inbox.md) — 6 tasks, da tabela ao endpoint | high |

Mudar o status da spec na tabela de specs/ para done, e a mesma linha de R-008 no RULES.md, no formato que ele já usa.

  • [ ] Step 3: Commit

bash git add ../../.project/docs git commit -m "docs: add incoming event processing rule"


Verificação ponta a ponta (depois do merge)

Fica fora deste plano, porque depende de staging e do admin do accounts:

  1. Confirmar que as tabelas solid_queue_* existem no banco principal de produção.
  2. Cadastrar ACCOUNTS_WEBHOOK_TOKEN nos secrets de staging e produção.
  3. No admin do accounts, criar a WebhookSubscription nectar-charges / INSTALLMENT_OVERDUE apontando para https://<host>/api/v1/incoming_events, com o mesmo token.
  4. Disparar uma parcela atrasada e conferir: OutgoingEvent delivered no accounts e IncomingEvent failed com no use case registered… depois de 3 tentativas no nectar-charges.