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.mdBranch: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_eventsantes do 2xx, deduplicado porevent_ide 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_idrepetido (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-001do 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:
- Confirmar que as tabelas
solid_queue_*existem no banco principal de produção. - Cadastrar
ACCOUNTS_WEBHOOK_TOKENnos secrets de staging e produção. - No admin do accounts, criar a
WebhookSubscriptionnectar-charges/INSTALLMENT_OVERDUEapontando parahttps://<host>/api/v1/incoming_events, com o mesmo token. - Disparar uma parcela atrasada e conferir:
OutgoingEventdeliveredno accounts eIncomingEventfailedcomno use case registered…depois de 3 tentativas no nectar-charges.