openreceive-rails

Mountable receive-only OpenReceive engine. The engine owns the OpenReceivePayment attempt model (statuses pending, settled, expired, failed, attention), its per-reference commit locking, settlement write-once, and the reconciliation state machine. The install generator mounts the routes and emits the initializer plus one migration creating both engine tables (openreceive_payments and the openreceive_meta reconcile gate):

bin/rails generate openreceive:install
bin/rails db:migrate

The generated migration supports PostgreSQL, SQLite, and MySQL, and seeds the shared schema_version; on its first database touch the engine refuses to operate a database whose stored schema version is newer than the gem.

The quickstart host contract is config.authorize, config.amount_for (the trusted price for a reference, or nil for a 404), and config.on_paid (run inside the settlement transaction, only for the first settled attempt for a reference). The generated initializer starts with the OpenReceive::LOGGING_ON_PAID placeholder, which only logs settlements — the engine warns every time your application boots until it is replaced. Hosts with a custom repository may instead configure resolve_checkout and on_checkout_created together as the advanced escape hatch. In production the engine builds the service (and its wallet preflight) eagerly when your application boots, so a missing NWC_URI or a spend-capable wallet stops the deploy instead of surfacing as checkout-time 500s.

Settlement runs on the request path by default: every engine route runs one opportunistic reconcile pass, serialized across all Puma workers by that durable openreceive_meta gate (config.opportunistic_reconcile disables or tunes it). The optional bin/rails openreceive:notifications worker listens for wallet notifications and reconciles periodically; OpenReceive::ReconcileJob and bin/rails openreceive:reconcile remain one-shot primitives. Closure of an unpaid attempt requires a successful wallet scan at or after expiry plus the shared grace window — a local clock alone never closes a row.

The engine inherits the host's protect_from_forgery: render csrf_meta_tags and the checkout client sends X-CSRF-Token from it. Independently of that, the shared handler refuses non-JSON bodies (415) and Sec-Fetch-Site: cross-site requests (403) before authorize runs.

Because the engine cannot see fulfillment that happens outside it, config.on_paid must be idempotent if any other path can also fulfill an order — the generated initializer shows the guarded transition. The receive-only wallet URI loads from ENV["NWC_URI"]; your application refuses to start when the connection advertises spend methods unless config.allow_spend_capable_wallet or OPENRECEIVE_ALLOW_SPEND_CAPABLE_NWC overrides it. Keep ordinary settings such as config.price_currencies in config/initializers/openreceive.rb.