Linear: Receive dollars from Cash App · CORE-2278 Decide on UX Owner: Stephen Margheim (Kappa) Scope: Backend + mobile-rn end-to-end MVP. Excludes the deferred items in the Linear Project's Scope/Out.
Add Lightning as a payment rail so senders with any Lightning-compatible wallet (Cash App, Strike, Wallet of Satoshi, etc.) can fund a ZAR wallet by paying a shareable link. Settlement is USDC on Solana, mediated by Flashnet's Orchestra API (POST /v1/pay-links) using amountFiatUsd + amountMode: "exact_out" — the API combination Flashnet enables on partner accounts with invoiced billing. ZAR is on this billing arrangement (confirmed enabled Jun 7 2026 by Ethan Marcus at Flashnet — see References §26).
The mechanics: the sender sees the exact USD amount the receiver requested, and the receiver gets that amount 1:1 as USDC — no fee deducted from the output. Flashnet bills ZAR for the platform fee at end-of-month at the negotiated rate. No platform-funded subsidy transaction, no fee disclosure to the user, no on-chain top-up. The "fees on us" UX is delivered through an out-of-band billing arrangement rather than a subsidy job.
Links are persistent (never auto-disabled) — a single link can receive multiple payments.
Cash App is the dominant Lightning wallet for US senders and is the v1 anchor in copy + iconography, but the underlying rail is wallet-agnostic.
The same primitive supports two senders in one flow:
- Self-onramp — user taps "Pay it myself", their phone opens their default Lightning wallet (Cash App for most US users) with the invoice prefilled.
- Request-to-others — user shares the
pay/<shortId>URL via iMessage/WhatsApp; recipient clicks, pays in their Lightning wallet.
v1 mobile surface is intentionally minimal (4 screens). Backend tracks the full Payment Request lifecycle so a richer UX can be added later without backend changes.
flowchart TD
A[Add Dollars sheet] -->|tap "Receive Lightning Payments"| B{First time?}
B -->|yes| C[Intro / value-prop screen]
B -->|no| D[Amount + memo entry]
C --> D
D --> E[Share / Pay screen]
E -->|Pay myself| F[cash.app deeplink]
E -->|Share with someone| G[native share sheet]
F --> H[Sender pays in Cash App]
G --> H
H --> I[Flashnet webhook<br/>order.completed]
I --> J[Net USDC delivered<br/>to wallet]
J --> K[Push notification +<br/>activity feed entry]
classDef inApp fill:#efebe0,stroke:#c4ad54,color:#221e18
classDef external fill:#e8f5ff,stroke:#386e9e,color:#221e18
class A,B,C,D,E inApp
class F,G,H,I,J,K external
- Product catalog
- New:
zar_lightning_paymentsProduct::Definition;LightningPaymentEnrollment(zero-task, AutoSwap-style intro) - Reused:
Product::Definition,Product::Enrollment,EnrollmentRegistry
- New:
- Domain model
- New:
PaymentRequest(flat table; Flashnet columns inline; data-only, no instance methods) - Reused:
ApplicationRecord
- New:
- Third-party I/O
- New:
ApiClient::Services::Flashnet(HTTParty client);ZarFlashnetSdk(app-bound wrapper, returns Servus responses);Webhook::FlashnetController(HMAC-verified) - Reused:
ApiClient::Base,ZarRainSdk(SDK pattern template),concerns/raincards_webhook_secured.rb
- New:
- Settlement
- New: Single on-chain Flashnet delivery, 1:1 USD→USDC (sender pays $X, receiver gets $X USDC). Flashnet bills ZAR's account at end-of-month for the platform fee — no subsidy or top-up tx required.
- Reused: —
- Events
- New:
:lightning_payment_received,:lightning_payment_failed - Reused: Servus
emits(in services) +Servus::Eventsubclasses withevent_namedeclaration + inlineinvoke(per backend CLAUDE.md). Event payload carries the order data; subscribers don't re-fetch from DB.
- New:
- API surface
- New:
POST /api/v1/lightning_payments/payment_requests - Reused:
Api::ApiController,validate_body,run_service, OpenAPI codegen
- New:
- Mobile state
- New:
lightningStore.tsZustand store - Reused: Mirrors
autoSwapStore.tsshape
- New:
- Mobile screens
- New: Lightning value-prop · new request · share/pay
- Reused:
TectonicItemCard,TectonicQrCode,PrimaryButton,TectonicCurrencyExchangeInput
- Notifications
- New:
lightning/receivednotification route - Reused:
mapNotificationToRoute(),useNotificationRouter
- New:
Code for this feature lives in a new Rails engine at backend/engines/zar_lightning_payments/, matching the precedent of zar_virtual_account, zar_bank_transfer, and other domain engines. Specifically:
In the engine (engines/zar_lightning_payments/):
- All controllers (
Api::V1::PaymentRequestsController,Webhooks::FlashnetController) - All event handlers (
LightningPaymentPaidHandler,LightningPaymentFailedHandler) - All services namespaced
ZarLightningPayments::* - The
Product::Enrollments::LightningPaymentEnrollmentclass - Routes, schemas, jbuilder views, engine seed for the Product Definition
In main backend (top-level):
PaymentRequestmodel — kept generic so it can grow into a multi-rail primitiveZarFlashnetSdk+ApiClient::Services::Flashnet— SDK + HTTP client pattern (matchesZarRainSdkliving at the top despite Rain being used by thezar_complianceengine)- Database migration
This split keeps the rail-specific machinery encapsulated while letting the model and SDK be reusable primitives for any future code that needs them.
ZAR is already registered as a Flashnet affiliate; keys in hand. Add to config/credentials/{environment}.yml.enc files, following the existing Walapay/Raincards layout:
flashnet:
server_key: fn_***** # backend bearer token; attributes fees to ZAR
client_key: fnp_**** # browser-side key — not used in v1 (no web surface)
webhook_signing_secret: <hex secret> # HMAC-SHA256 secret for inbound webhooks
base_url: https://orchestration.flashnet.xyzAccessed via Rails.application.credentials.flashnet[:server_key] etc. ZAR is already a registered affiliate, so fee attribution is implicit in the server_key; no affiliateId needs to be passed in request bodies. The client_key is reserved for a future browser-side surface (none in v1) — included here so credentials are loaded once and don't need a second rotation when/if web support lands.
Configure the ApiClient::Services::Flashnet client in the existing API clients initializer (config/initializers/api_clients.rb):
ApiClient::Services::Flashnet.configure do |config|
creds = Rails.application.credentials.flashnet
config.base_url = creds[:base_url] || 'https://orchestration.flashnet.xyz'
config.server_key = creds[:server_key]
config.webhook_signing_secret = creds[:webhook_signing_secret]
config.timeout = 30
endFollows the AutoSwap pattern from [[reference_value_prop_intro_pattern]] — zero Product::Task records; the intro screen advances state via the existing enrollment-next endpoint.
Add to db/seeds/03_product_definitions.rb:
{
code: 'zar_lightning_payments',
name: 'Lightning Payments',
active: false, # kill-switch off at launch; flip when rolling out
auto_provision: true, # matches sibling inbound rails (VA, Bank Transfer)
supported_regions: GLOBAL, # no receiver-side jurisdictional restriction
location_policy: nil # no signal-based gating
}New file backend/app/models/product/enrollments/lightning_payment_enrollment.rb (host app — matches every existing enrollment subclass; none of MultiSigEnrollment, VirtualAccountEnrollment, BankTransferEnrollment, AutoSwapEnrollment, etc. live in an engine):
class Product::Enrollments::LightningPaymentEnrollment < Product::Enrollment
# No Product::Task records — AutoSwap-style intro pattern.
# State advances directly from `available` → `enabled` on first user action
# (taps Continue on the value-prop screen, which calls the enrollments/next endpoint).
def onboard!
# No prerequisite tasks. Move straight to enabled on first invocation.
enable! unless enabled?
end
def next_step
enabled? ? :complete : :acknowledge_intro
end
endconfig/initializers/product_enrollment_registrations.rb:
Product::EnrollmentRegistry.register(
:zar_lightning_payments,
Product::Enrollments::LightningPaymentEnrollment
)Single flat table at the top-level PaymentRequest. The intent is for this to grow into a generic primitive that can carry additional rail-specific columns over time (or, if a future rail diverges enough, get extracted into something polymorphic). For v1 it carries only Flashnet/Lightning fields.
The model is intentionally dumb — no instance methods that drive state transitions, no emits declarations. State transitions belong to services per the Servus pattern; the model just holds data.
# db/migrate/YYYYMMDDHHMMSS_create_payment_requests.rb
class CreatePaymentRequests < ActiveRecord::Migration[8.1]
def change
create_table :payment_requests do |t|
t.references :user, null: false, foreign_key: true, index: true
# Link metadata — set once at creation, never mutated
t.decimal :amount_fiat_usd, precision: 12, scale: 2, null: false
t.string :memo
# Flashnet identifiers
t.string :flashnet_pay_link_id, null: false
t.string :flashnet_short_url, null: false
t.timestamps
t.index :flashnet_pay_link_id, unique: true
end
end
endThe schema is intentionally tiny because PaymentRequest records the link, not the payment(s). Per the "let the links live" directive, a single link can be paid multiple times. Per-payment data — received amount, fee, paid_at, order id — lives on Transaction::LightningReceived records (§8.3); one Transaction per completed order.
No status enum — the link has no lifecycle. Created, lives forever (Pay Links never expire on Flashnet's side and we don't disable them anymore). If we ever need soft-delete, add a cancelled_at timestamp later.
No paid_at, received_amount_usdc, fee_amount_usdc — per-payment fields that lived on this table when the model assumed single-use. With multi-use links they'd be misleading (which payment's amount? the first? the last? the total?). All moved to Transaction::LightningReceived, which keys per-payment.
No failure_code, failure_message — order.failed events log to Sentry + alert support; we don't persist a DB record for the failed payment. A failure on one order doesn't affect the link's ability to receive more payments.
No expires_at — Pay Links never expire; this hasn't been needed since the prior simplification.
app/models/payment_request.rb:
class PaymentRequest < ApplicationRecord
belongs_to :user
# Two URL views of the same Pay Link:
# shareable_url → landing page with OG preview (best for messages/email)
# deeplink_url → /go variant, skips the landing page (best for in-app self-pay)
alias_attribute :shareable_url, :flashnet_short_url
def deeplink_url = "#{flashnet_short_url}/go"
endThat's the whole model. No state, no enum, no methods that mutate. Aggregate stats ("how much has been received against this link?") aren't surfaced in v1; if needed later, query via Transaction::LightningReceived.where("provider_link->>'flashnet_pay_link_id' = ?", pr.flashnet_pay_link_id) (or use the existing for_provider scope on Transaction::Base).
Two layers, matching the canonical pattern set by ZarRainSdk (backend/app/sdks/zar_rain_sdk.rb):
ApiClient::Services::Flashnet— HTTParty-based HTTP client (underlib/api_client/services/). Translates request bodies to JSON, attaches the bearer header, raises typedApiClient::*errors. No domain knowledge.ZarFlashnetSdk— app-bound SDK module (extend self, no instance state). Wraps the client, returnsServus::Support::Responsefrom every method, translatesApiClient::ApiErrorinto typedZarFlashnetSdk::*errors, and parses raw response hashes into immutable value objects (ZarFlashnetSdk::PayLink).
Domain code (the create-payment-request service, the webhook dispatcher) calls the SDK, never the HTTP client directly. There are no CreatePayLink::Service / DisablePayLink::Service wrapper objects — the SDK itself is the domain API.
backend/lib/api_client/services/flashnet.rb:
module ApiClient
module Services
class Flashnet < ApiClient::Base
class Configuration < ApiClient::Configuration
attr_accessor :server_key, :webhook_signing_secret
end
def create_pay_link(body:, idempotency_key:)
post('/v1/pay-links',
body: body,
headers: { 'X-Idempotency-Key' => idempotency_key }
)
end
private
def auth_headers
{ 'Authorization' => "Bearer #{self.class.config.server_key}" }
end
end
end
endbackend/app/sdks/zar_flashnet_sdk.rb:
# App-bound wrapper around ApiClient::Services::Flashnet, modeled on ZarRainSdk.
# Every method returns Servus::Support::Response. Callers use the familiar
# `result.success? / .data / .error` flow.
module ZarFlashnetSdk
class Error < StandardError; end
class NotFoundError < Error; end
class TransientError < Error; end
extend self
def create_pay_link(recipient_address:, amount_fiat_usd:, idempotency_key:, label: nil)
safe_call do
response = client.create_pay_link(
body: {
destinationChain: 'solana',
destinationAsset: 'USDC',
recipientAddress: recipient_address,
# `amountFiatUsd` + `amountMode: "exact_out"` is the combination Flashnet enables
# for partners with invoiced billing (1:1 USD→USDC; fees billed monthly to ZAR).
# Without invoicing this combination would 4xx — but invoicing is enabled on our
# account (confirmed Jun 7 2026 by Ethan Marcus, see References §26).
amountFiatUsd: amount_fiat_usd.to_s,
amountMode: 'exact_out',
label: label.to_s.truncate(255).presence
}.compact,
idempotency_key: idempotency_key
)
PayLink.from(response['payLink'])
end
end
private
def client
ApiClient::Services::Flashnet.client
end
def safe_call
Servus::Support::Response.new(true, yield, nil)
rescue ApiClient::TransientError => e
Servus::Support::Response.new(false, nil, TransientError.new(e.message))
rescue ApiClient::ApiError => e
Servus::Support::Response.new(false, nil, Error.new(e.message))
end
endbackend/app/sdks/zar_flashnet_sdk/pay_link.rb:
module ZarFlashnetSdk
# Immutable value object for a Flashnet Pay Link. Raw response stays
# reachable via `raw` for forensics / new fields.
PayLink = Data.define(:id, :short_url, :raw) do
# /go variant skips Flashnet's OG-preview landing page; best for in-app self-pay.
def deeplink_url = "#{short_url}/go"
def self.from(hash)
new(id: hash['id'], short_url: hash['shortUrl'], raw: hash)
end
end
endNo affiliateId in the request body — fees route to ZAR implicitly via the server_key bearer token configured on ApiClient::Services::Flashnet. The on-link short_id slug isn't exposed as a separate field since callers never need it independently of the URL (parsable from short_url if ever needed).
engines/zar_lightning_payments/app/services/zar_lightning_payments/create_payment_request/service.rb:
module ZarLightningPayments
module CreatePaymentRequest
class Service < ApplicationService
option :user
option :amount_fiat_usd # BigDecimal
option :memo, default: -> { nil }
def call
ensure_enrolled!
ensure_within_bounds!
idempotency_key = "paylink:user:#{user.id}:#{SecureRandom.hex(8)}"
link_result = ZarFlashnetSdk.create_pay_link(
recipient_address: user.squads_multisig.public_key,
amount_fiat_usd: amount_fiat_usd,
label: memo,
idempotency_key: idempotency_key
)
return failure(link_result.error.message) unless link_result.success?
pay_link = link_result.data
pr = PaymentRequest.create!(
user: user,
amount_fiat_usd: amount_fiat_usd,
memo: memo,
flashnet_pay_link_id: pay_link.id,
flashnet_short_url: pay_link.short_url
)
success(pr)
end
private
# Reuses the existing `User#enrollment(code)` helper from
# app/models/concerns/has_product_enrollments.rb. Returns nil if not
# provisioned. `state_enabled?` is the enum predicate (Rails enum prefix).
def ensure_enrolled!
enrollment = user.enrollment('zar_lightning_payments')
raise NotEnrolledError unless enrollment&.state_enabled?
end
def ensure_within_bounds!
# Aligned with Flashnet's documented amountFiatUsd range ($1.00–$50,000.00).
# Match these bounds exactly — Flashnet rejects out-of-range with HTTP 400 and we want
# to surface a friendly error before the API call.
return if amount_fiat_usd.between?(BigDecimal('1'), BigDecimal('50000'))
raise OutOfBoundsError, "Lightning payment requests bounded to $1.00–$50,000.00"
end
end
end
endNo expires_at — links never expire (§3.1). If analytics or observability hooks become useful later, add an emits :lightning_payment_request_created, on: :success line and create a corresponding LightningPaymentRequestCreatedEvent < Servus::Event — mirrors the pattern in backend/app/events/multisig_wallet_created_event.rb.
backend/app/controllers/concerns/flashnet_webhook_secured.rb (HOST app, alongside walapay_webhook_secured.rb and raincards_webhook_secured.rb) — mirrors RaincardsWebhookSecured (direct HMAC-SHA256 with X-Flashnet-Signature + X-Flashnet-Timestamp). All webhook plumbing (base controller, concrete controllers, HMAC concerns) lives in the host; engines hold business logic but don't own the webhook surface.
module FlashnetWebhookSecured
extend ActiveSupport::Concern
included do
before_action :verify_flashnet_signature!
skip_before_action :verify_authenticity_token
end
private
def verify_flashnet_signature!
return head :unauthorized unless valid_signature? && fresh_timestamp?
end
def valid_signature?
sig = request.headers['X-Flashnet-Signature']
ts = request.headers['X-Flashnet-Timestamp']
return false if sig.blank? || ts.blank?
payload = "#{ts}.#{request.raw_post}"
expected = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)
ActiveSupport::SecurityUtils.secure_compare(expected, sig)
end
def fresh_timestamp?
ts = request.headers['X-Flashnet-Timestamp'].to_i
(Time.current.to_i - ts).abs < 5.minutes.to_i
end
def signing_secret
Rails.application.credentials.flashnet[:webhook_signing_secret]
end
endLives in the host app, not the engine — same convention as app/controllers/webhook/walapay_controller.rb. The Webhook::WebhookController base is literally just class WebhookController < ActionController::API (verified by reading backend/app/controllers/webhook/webhook_controller.rb). Engine-isolated namespacing is for the rail's API surface (§9), not for webhooks.
Pattern: inherits WebhookController, includes a webhook-secured concern for HMAC, uses process_default as the action, and delegates to the engine's handler service via .call_async so the controller returns 200 immediately and async processing runs in Sidekiq.
backend/app/controllers/webhook/flashnet_controller.rb (HOST app):
module Webhook
class FlashnetController < WebhookController
include FlashnetWebhookSecured
def process_default
ZarLightningPayments::HandleWebhook::Service.call_async(
event: webhook_params[:event],
timestamp: webhook_params[:timestamp],
data: webhook_params[:data].to_h
)
head(:ok)
rescue StandardError => e
Rails.error.report(e, handled: true)
head(:internal_server_error) # Flashnet retries on 5xx
end
private
def webhook_params
params.permit(:event, :timestamp, data: {})
end
end
endRoute added to host's backend/config/routes/webhook.rb (alongside walapay/default, raincards/default):
namespace :webhook, defaults: { format: :json } do
post 'flashnet/default', to: 'flashnet#process_default'
end.call_async is a Servus::Base class method that every ApplicationService inherits — queues the call as a Sidekiq job. See app/services/translations/translate_record/service.rb for a typical caller.
engines/zar_lightning_payments/app/services/zar_lightning_payments/handle_webhook/service.rb — translates the inbound webhook event into a Servus event publication. With multi-use links there's no per-link state to mutate; per-payment data lives on Transaction::LightningReceived records created by event subscribers.
module ZarLightningPayments
module HandleWebhook
class Service < ApplicationService
option :event
option :timestamp
option :data
def call
pr = PaymentRequest.find_by!(flashnet_pay_link_id: data[:payLinkId])
case event
when 'order.processing', 'order.confirming'
# no-op — links are persistent; we don't disable on payment commit.
when 'order.completed'
ZarLightningPayments::RecordPaymentReceived::Service.call(
payment_request: pr,
order: data.slice(:id, :amount_out, :fee_amount)
)
when 'order.failed'
ZarLightningPayments::AlertOnPaymentFailure::Service.call(
payment_request: pr,
order_id: data[:id],
code: data.dig(:error, :code),
message: data.dig(:error, :message)
)
when 'order.refunding', 'order.refunded'
# noted; refunds settle directly between Flashnet and the sender.
end
success(true)
end
end
end
endIdempotency strategy — no global dedupe layer, no Redis SETNX. The activity feed entry (the only persistent side-effect) is keyed on flashnet_order_id in Transaction::LightningReceived via a unique constraint on provider_link, so a replayed order.completed is caught at the DB level. Matches the Walapay / Raincards pattern — replay safety is a property of the downstream, not a separate utility.
Note on unfulfilled status: not a webhook event — only visible via GET /v1/orchestration/status when a per-click invoice expires unpaid. Each click mints a fresh invoice; an unfulfilled order doesn't affect the persistent link.
Each terminal webhook event is its own service so it can declaratively emit a Servus event on :success. Services don't mutate PaymentRequest (the model has no state to mutate); they emit an event whose payload carries everything downstream needs. Services use the dry-initializer option :foo pattern that every existing ApplicationService uses.
engines/zar_lightning_payments/app/services/zar_lightning_payments/record_payment_received/service.rb:
module ZarLightningPayments
module RecordPaymentReceived
class Service < ApplicationService
emits :lightning_payment_received, on: :success do |result|
result.data
end
option :payment_request
option :order # { id:, amount_out:, fee_amount: } from Flashnet webhook
def call
success({
payment_request_id: payment_request.id,
user_id: payment_request.user_id,
memo: payment_request.memo,
flashnet_pay_link_id: payment_request.flashnet_pay_link_id,
flashnet_order_id: order[:id],
received_amount_atomic: order[:amount_out].to_s,
fee_amount_atomic: order[:fee_amount].to_s
})
end
end
end
endengines/zar_lightning_payments/app/services/zar_lightning_payments/alert_on_payment_failure/service.rb:
module ZarLightningPayments
module AlertOnPaymentFailure
class Service < ApplicationService
emits :lightning_payment_failed, on: :success do |result|
result.data
end
option :payment_request
option :order_id
option :code
option :message
def call
success({
payment_request_id: payment_request.id,
user_id: payment_request.user_id,
flashnet_pay_link_id: payment_request.flashnet_pay_link_id,
flashnet_order_id: order_id,
failure_code: code,
failure_message: message
})
end
end
end
endIdempotency lives in the downstream activity-feed creation (DB unique constraint on provider_link's provider_id), so a replayed order.completed doesn't double-record. These services are thin and emit-only — they don't try to short-circuit replays themselves.
Per backend convention (CLAUDE.md): services emit events; event classes in app/events/ are thin dispatchers — only event_name, invoke, and an optional schema. Event classes inherit from Servus::Event, declare which event symbol they listen to via event_name :foo, and use inline invoke blocks to fan out to services.
The Lightning rail emits three events from services:
| Event | Emitted by | Payload |
|---|---|---|
:lightning_payment_received |
ZarLightningPayments::RecordPaymentReceived::Service |
payment_request_id, user_id, memo, flashnet_pay_link_id, flashnet_order_id, received_amount_atomic, fee_amount_atomic |
:lightning_payment_failed |
ZarLightningPayments::AlertOnPaymentFailure::Service |
payment_request_id, user_id, flashnet_pay_link_id, flashnet_order_id, failure_code, failure_message |
engines/zar_lightning_payments/app/events/lightning_payment_received_event.rb:
class LightningPaymentReceivedEvent < Servus::Event
event_name :lightning_payment_received
schema payload: {
type: 'object',
description: 'Emitted when a Lightning payment lands against a payment-request link. Fires once per completed order; a single link can emit this multiple times.',
required: %w[payment_request_id user_id flashnet_pay_link_id flashnet_order_id received_amount_atomic fee_amount_atomic],
properties: {
payment_request_id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid' },
memo: { type: 'string' },
flashnet_pay_link_id: { type: 'string' },
flashnet_order_id: { type: 'string' },
received_amount_atomic: { type: 'string' },
fee_amount_atomic: { type: 'string' }
}
}
invoke ZarLightningPayments::CreateActivityFeedEntry::Service, async: true do |payload|
{
payment_request_id: payload[:payment_request_id],
flashnet_order_id: payload[:flashnet_order_id],
received_amount_atomic: payload[:received_amount_atomic],
fee_amount_atomic: payload[:fee_amount_atomic]
}
end
invoke ZarLightningPayments::SendReceivedPushNotification::Service, async: true do |payload|
{
user_id: payload[:user_id],
memo: payload[:memo],
flashnet_order_id: payload[:flashnet_order_id],
received_amount_atomic: payload[:received_amount_atomic]
}
end
endThe event class fans out to two things on emission: (1) the activity feed entry, (2) the push notification. Both run async via Sidekiq through Servus's call_async machinery. Payload carries everything — subscribers don't re-fetch from the DB.
engines/zar_lightning_payments/app/events/lightning_payment_failed_event.rb:
class LightningPaymentFailedEvent < Servus::Event
event_name :lightning_payment_failed
schema payload: {
type: 'object',
required: %w[payment_request_id user_id flashnet_pay_link_id flashnet_order_id failure_code],
properties: {
payment_request_id: { type: 'string', format: 'uuid' },
user_id: { type: 'string', format: 'uuid' },
flashnet_pay_link_id: { type: 'string' },
flashnet_order_id: { type: 'string' },
failure_code: { type: 'string' },
failure_message: { type: 'string' }
}
}
invoke ZarLightningPayments::AlertSupportOnFailure::Service, async: true do |payload|
{
payment_request_id: payload[:payment_request_id],
flashnet_order_id: payload[:flashnet_order_id],
failure_code: payload[:failure_code],
failure_message: payload[:failure_message]
}
end
endv1 surfaces failures only to internal support (Slack/PagerDuty), not to the user. The link stays live regardless — a failed payment on one order doesn't affect subsequent attempts.
Creates a Transaction::LightningReceived STI record so the deposit surfaces in the existing wallet activity feed (GET /api/v1/transactions?type=wallet).
engines/zar_lightning_payments/app/models/transaction/lightning_received.rb:
module Transaction
class LightningReceived < Transaction::Base
TYPE_NAME = 'LIGHTNING_RECEIVED'
def description
I18n.t("models.transactions.lightning_received.#{direction}")
end
end
endengines/zar_lightning_payments/app/services/zar_lightning_payments/create_activity_feed_entry/service.rb:
module ZarLightningPayments
module CreateActivityFeedEntry
class Service < ApplicationService
option :payment_request_id
option :flashnet_order_id
option :received_amount_atomic
option :fee_amount_atomic
def call
pr = PaymentRequest.find(payment_request_id)
# Idempotent on the Flashnet ORDER id (not the pay_link_id) — a single link
# can receive multiple payments, each is its own activity feed entry.
# `for_provider` is the canonical JSONB query scope on Transaction::Base
# (see app/models/transaction/base.rb:144 — used by Raincards importer).
existing = Transaction::LightningReceived
.for_provider('flashnet', flashnet_order_id)
.first
return success(existing) if existing
tx = Transaction::LightningReceived.create!(
user_id: pr.user_id,
transactable: pr.user.squads_multisig,
direction: 'credit',
status: 'completed',
# `has_amount :value` (Transaction::Base:77) means assigning a Currency::Amount
# instance to `value:` populates value_atomic + value_symbol automatically.
value: Currency::USDCAmount.new(received_amount_atomic, from: :atomic),
posted_at: Time.current,
provider_link: { provider: 'flashnet', provider_id: flashnet_order_id },
metadata: {
payment_request_id: pr.id,
memo: pr.memo,
requested_usd: pr.amount_fiat_usd.to_s,
flashnet_pay_link_id: pr.flashnet_pay_link_id,
fee_amount_atomic: fee_amount_atomic
}
)
success(tx)
end
end
end
endThe activity feed entry value is the net delivered amount Flashnet sent (passed in as received_amount_atomic), not the requested amount. Users see the actual landed amount — consistent with their wallet balance — and the metadata records the requested-vs-received delta for support/forensics. The JSONB idempotency key is the order ID, not the pay-link ID — multi-use links mean multiple payments can land against the same link, and each gets its own activity feed entry. The existing unique index index_transactions_on_provider_link_unique on (provider_link->>'provider', provider_link->>'provider_id') enforces uniqueness at the DB level as a belt-and-suspenders safety net against webhook retries.
Add i18n key in engines/zar_lightning_payments/config/locales/en.yml (Rails auto-discovers engine locales — no host wiring needed):
en:
models:
transactions:
lightning_received:
credit: "Received via Cash App"Adding the new transaction subtype requires two mobile edits so the activity feed renders it correctly:
- Enum value in
mobile-rn/domains/wallet/types/transaction.types.ts:export enum WalletTransactionType { // ... existing LIGHTNING_RECEIVED = 'LIGHTNING_RECEIVED', }
- Icon mapping in
mobile-rn/domains/wallet/utils/transactionDisplayMapper.ts— bothgetTransactionIconandgetTransactionIconColor— pick an existing Tectonic icon ('cashNote'or'arrowDown'work for v1):if (tx.type === WalletTransactionType.LIGHTNING_RECEIVED) return 'cashNote';
No new screen and no new endpoint — the existing wallet activity feed (GET /api/v1/transactions?type=wallet) renders the row automatically once the type mapping is wired.
engines/zar_lightning_payments/app/services/zar_lightning_payments/send_received_push_notification/service.rb:
module ZarLightningPayments
module SendReceivedPushNotification
class Service < ApplicationService
option :user_id
option :memo, default: -> { nil }
option :flashnet_order_id
option :received_amount_atomic
def call
amount = Currency::USDCAmount.new(received_amount_atomic, from: :atomic)
# Uses the existing canonical push dispatcher (see app/services/notifications/push_dispatcher/service.rb).
# idempotency_key keyed on flashnet_order_id — guarantees one push per completed order
# (a second payment to the same link → different order id → distinct notification).
Notifications::PushDispatcher::Service.call(
user_id: user_id,
title: "$#{amount.ui_amount} received",
body: memo.presence || 'New Lightning deposit',
data: { action: 'navigateTo', path: '/wallet/activity' },
idempotency_key: "lightning_received:#{flashnet_order_id}"
)
success(true)
end
end
end
endMobile maps path: '/wallet/activity' to the existing activity feed route (see §13).
POST /api/v1/lightning_payments/payment_requests
# engines/zar_lightning_payments/app/controllers/zar_lightning_payments/payment_requests_controller.rb
# Matches the VA pattern (engines/zar_virtual_account/app/controllers/zar_virtual_account/accounts_controller.rb):
# engine-namespaced controller class, leading `::` to reach the host's Api::ApiController.
module ZarLightningPayments
class PaymentRequestsController < ::Api::ApiController
before_action :require_lightning_payment_enrollment, only: %i[create]
def create
# Schema key follows the zar_bank_transfer pattern: leading engine namespace +
# endpoints path. See zar_bank_transfer/app/controllers/.../transfers_controller.rb:38
# for the verbatim shape.
body = validate_body('zar_lightning_payments::endpoints::api::zar_lightning_payments::payment_requests::create')
return unless body
run_service ZarLightningPayments::CreatePaymentRequest::Service,
user: current_user,
amount_fiat_usd: BigDecimal(body[:data][:amount_fiat_usd]),
memo: body[:data][:memo]
end
private
# Uses the `User#enrollment(code)` helper from HasProductEnrollments concern
# (app/models/concerns/has_product_enrollments.rb). state_enabled? is the
# enum predicate from `enum state: { ... }, _prefix: :state` on Product::Enrollment.
def require_lightning_payment_enrollment
enrollment = current_user.enrollment('zar_lightning_payments')
unprocessable_content('Not enrolled in Lightning Payments') unless enrollment&.state_enabled?
end
end
endEngine route file engines/zar_lightning_payments/config/routes.rb:
ZarLightningPayments::Engine.routes.draw do
resources :payment_requests, only: %i[create]
endHost mounts the engine inside the existing api/v1 namespace block in backend/config/routes/api.rb (next to ZarBankTransfer, ZarCashExchange, ZarGold mounts, which all use this pattern):
mount ZarLightningPayments::Engine, at: '/lightning_payments', as: :zar_lightning_paymentsFinal URL: POST /api/v1/lightning_payments/payment_requests. The engine-named mount path is the standard convention (only ZarVirtualAccount mounts at '/'; everything else — bank_transfers, zar_gold, zar_cash_exchange — uses an engine-named segment).
engines/zar_lightning_payments/config/schemas/endpoints/api/zar_lightning_payments/payment_requests/create.yaml — engine-namespaced path matching the zar_bank_transfer convention (config/schemas/endpoints/api/zar_bank_transfer/...):
type: object
required: [data]
properties:
data:
type: object
required: [amount_fiat_usd]
properties:
amount_fiat_usd:
type: string
pattern: '^\d{1,5}(\.\d{2})?$' # "50.00" — whole-cent USD
memo:
type: string
maxLength: 255The service success returns the persisted PaymentRequest. Jbuilder view at engines/zar_lightning_payments/app/views/zar_lightning_payments/payment_requests/create.json.jbuilder — Rails resolves the view path from the controller's engine namespace, matching the zar_bank_transfer view layout (app/views/zar_bank_transfer/transfers/...):
{
"data": {
"id": "01234567-...",
"amount_fiat_usd": "50.00",
"memo": "Antigua walking tour",
"status": "pending",
"shareable_url": "https://orchestration.flashnet.xyz/pay/aB3kF7",
"deeplink_url": "https://orchestration.flashnet.xyz/pay/aB3kF7/go"
}
}deeplink_url is the /go variant of the Pay Link short URL, which skips Flashnet's OG-preview landing page and goes straight to per-click quote creation + Cash App redirect. The Pay Links API doesn't return a per-link cash.app/launch/lightning/... URL because each click mints a fresh invoice — /go is the closest equivalent and is what the mobile app uses for "Pay it myself."
Add the endpoint to config/openapi.yaml. Mobile picks it up via npm run api:generate → generated function appears at ~/api/generated/payment_requests/.
New domain at mobile-rn/domains/wallet/lightning/, mirroring mobile-rn/domains/wallet/autoswap/:
domains/wallet/lightning/
├── lightningStore.ts # Zustand store
├── index.ts # public exports
└── screens/
├── LightningValuePropositionScreen.tsx # first-time intro
├── NewPaymentRequestScreen.tsx # amount + memo
└── SharePaymentRequestScreen.tsx # QR, share, pay-self
Routes at mobile-rn/app/routes/(main)/lightning/:
app/routes/(main)/lightning/
├── value-proposition.tsx
├── new.tsx
└── share/[id].tsx
After adding the files, run npm run routes:generate to regenerate mobile-rn/config/generated/paths.generated.ts — that's how Paths.main.lightning.value_proposition / .new / .share.$id(id) become callable from the rest of the app. (Source: mobile-rn/config/routes.ts re-exports from the generated file; the codegen reads the app/routes/ directory tree.)
mobile-rn/domains/wallet/lightning/lightningStore.ts — mirrors autoSwapStore.ts:
import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { endpointsApiV1ProductEnrollmentsNext } from '~/api/generated/product/product';
import { getLightningPaymentEnrollment } from '~/api/generated/product-enrollments/product-enrollments';
const VALUE_PROP_SEEN_KEY = 'zar_lightning_value_prop_seen';
type EnrollmentState = 'available' | 'onboarding' | 'enabled' | 'ineligible' | 'disabled';
interface LightningState {
enrollmentId: string | null;
enrollmentState: EnrollmentState | null;
valuePropSeen: boolean;
isHydrated: boolean;
hydrate: () => Promise<void>;
isEligible: () => boolean; // can this user see the card at all?
navigate: () => 'valueProposition' | 'newRequest' | 'unavailable';
acknowledge: () => Promise<{ success: boolean }>;
}
export const useLightningStore = create<LightningState>((set, get) => ({
enrollmentId: null,
enrollmentState: null,
valuePropSeen: false,
isHydrated: false,
hydrate: async () => {
// Phase 1: device-local flag (mirrors AutoSwap pattern)
const seen = (await AsyncStorage.getItem(VALUE_PROP_SEEN_KEY)) === 'true';
set({ valuePropSeen: seen });
// Phase 2: backend enrollment state
const { data } = await getLightningPaymentEnrollment();
set({
enrollmentId: data.id,
enrollmentState: data.state,
isHydrated: true,
});
},
// Card is shown when the user can theoretically use the product —
// any non-terminal enrollment state. New users land at 'available' and
// still see the card; tapping it routes them to the intro, where the
// backend state machine advances on Continue.
isEligible: () => {
const s = get().enrollmentState;
return s !== null && s !== 'ineligible' && s !== 'disabled';
},
// Intro screen shows whenever either the backend OR device thinks the user
// hasn't fully acknowledged. Covers fresh users (state=available) AND
// already-enabled users opening the app on a new device (state=enabled but
// !seen locally).
navigate: () => {
const { enrollmentState, valuePropSeen } = get();
if (enrollmentState === null
|| enrollmentState === 'ineligible'
|| enrollmentState === 'disabled') {
return 'unavailable';
}
if (enrollmentState !== 'enabled' || !valuePropSeen) {
return 'valueProposition';
}
return 'newRequest';
},
// Idempotent: calling /next when already enabled is a no-op server-side.
// Always sets the local flag so subsequent taps skip the intro on this device.
acknowledge: async () => {
const { enrollmentId, enrollmentState } = get();
if (!enrollmentId) return { success: false };
if (enrollmentState !== 'enabled') {
await endpointsApiV1ProductEnrollmentsNext(enrollmentId);
}
await AsyncStorage.setItem(VALUE_PROP_SEEN_KEY, 'true');
set({ valuePropSeen: true, enrollmentState: 'enabled' });
return { success: true };
},
}));
export const Lightning = {
hydrate: () => useLightningStore.getState().hydrate(),
navigate: () => useLightningStore.getState().navigate(),
activate: () => useLightningStore.getState().acknowledge(),
};Edit mobile-rn/domains/wallet/components/AddMoneyBottomSheet.tsx to append a third TectonicItemCard:
import { useLightningStore } from '~/domains/wallet/lightning/lightningStore';
// inside the component:
const isEligible = useLightningStore((s) => s.isEligible());
const handleLightning = () => {
const target = useLightningStore.getState().navigate();
if (target === 'valueProposition') {
router.push(Paths.main.lightning.value_proposition);
} else if (target === 'newRequest') {
router.push(Paths.main.lightning.new);
}
// 'unavailable' → card is hidden; we don't reach this branch
};
// in JSX, after the existing two cards:
{isEligible && (
<TectonicItemCard
leadingIcon={lightningIcons} // Lightning bolt + Cash App chip (Cash App as v1 anchor)
title="Receive from Cash App"
subtitle="Get paid instantly via Cash App, Strike, or any Lightning app."
onPress={handleLightning}
/>
)}Card visibility gates on isEligible() — true for any non-terminal enrollment state (available, onboarding, or enabled). A freshly provisioned user lands at available and still sees the card; tapping it routes them through the intro screen, which advances the enrollment to enabled via endpointsApiV1ProductEnrollmentsNext on Continue. Users at ineligible or disabled — or with no enrollment record at all — never see the card. During gradual rollout, the Product Definition's active: false keeps the card hidden globally (no enrollments get provisioned); selectively batch-enrolling beta users flips it on for them alone.
mobile-rn/domains/wallet/lightning/screens/LightningValuePropositionScreen.tsx — modeled directly on AutoSwapValuePropositionScreen.tsx. Structure:
- Header illustration (Cash App glyph in a brand-tinted hero card)
- Title: "Get paid in dollars from any Lightning app"
- Three benefit rows:
- Instant USDC — "Funds land in your wallet within seconds of the sender tapping pay."
- Any Lightning app — "Works with Cash App, Strike, Wallet of Satoshi, and more."
- Fees on us — "You receive exactly what you ask for. We cover the conversion fees."
- Fine print: "By continuing, you agree to ZAR's Terms of Service. Payments are processed by Flashnet, a third party."
PrimaryButton"Continue"
Route file app/routes/(main)/lightning/value-proposition.tsx:
import { router } from 'expo-router';
import { Lightning } from '~/domains/wallet/lightning';
import { LightningValuePropositionScreen } from '~/domains/wallet/lightning/screens/LightningValuePropositionScreen';
import { Paths } from '~/app/paths';
export default function LightningValuePropositionRoute() {
const handleContinue = async () => {
const { success } = await Lightning.activate();
if (success) router.replace(Paths.main.lightning.new);
};
return <LightningValuePropositionScreen onContinue={handleContinue} />;
}mobile-rn/domains/wallet/lightning/screens/NewPaymentRequestScreen.tsx:
export function NewPaymentRequestScreen() {
const [amount, setAmount] = useState('');
const [memo, setMemo] = useState('');
const { create, isSubmitting } = useCreatePaymentRequest();
const onContinue = async () => {
const { data } = await create({ amount_fiat_usd: amount, memo });
router.push(Paths.main.lightning.share.$id(data.id));
};
return (
<Screen>
<Heading>How much?</Heading>
<TectonicCurrencyExchangeInput
base={{ code: 'USD' }}
value={amount}
on_change={setAmount}
/>
<TextInput
placeholder="What's it for? (optional)"
value={memo}
onChangeText={setMemo}
maxLength={255}
/>
<InfoNote>Recipients pay via Cash App or any Lightning app. You receive the full amount — fees on us.</InfoNote>
<PrimaryButton text="Continue" onPress={onContinue} loading={isSubmitting} />
</Screen>
);
}No standalone fees disclosure sheet — with Flashnet's invoiced billing, the user receives 1:1 USDC for the requested USD amount, so there's nothing to disclose at the point of creation. The inline info note (one sentence) is the only fee mention surfaced to the user; the engineering reality of monthly invoicing is invisible to them. If the fee model ever changes back to per-tx deduction, restore the LightningFeesSheet component (deleted in this revision).
Uses useSafeMutation from app/hooks/useSafeMutation.ts — the canonical wrapper that prevents double-submit and exposes isSubmitting (alias for isPending). Same pattern AutoSwap and every other financial mutation uses.
mobile-rn/domains/wallet/lightning/hooks/useCreatePaymentRequest.ts:
export function useCreatePaymentRequest() {
const mutation = useSafeMutation({
mutationFn: (params: { amount_fiat_usd: string; memo?: string }) =>
endpointsApiV1LightningPaymentsPaymentRequestsCreate({ data: params }),
});
return {
create: mutation.safeMutateAsync, // guarded — no-op if a mutation is in-flight
isSubmitting: mutation.isSubmitting,
};
}In NewPaymentRequestScreen, wire error handling via the standard TanStack onError callback at the safeMutateAsync call site:
const onContinue = async () => {
try {
const { data } = await create({ amount_fiat_usd: amount, memo });
router.push(Paths.main.lightning.share.$id(data.id));
} catch (err) {
// useSafeMutation already serializes the error; surface via the standard
// toast / banner used by other financial-create flows.
showErrorToast(parseApiError(err));
}
};The 422 "Not enrolled" and out-of-bounds errors get specific copy; everything else falls back to generic "Could not create your request — please try again."
mobile-rn/domains/wallet/lightning/screens/SharePaymentRequestScreen.tsx:
export function SharePaymentRequestScreen({ paymentRequestId }: Props) {
const { data, isLoading } = usePaymentRequest(paymentRequestId);
if (isLoading) return <Loading />;
const { amount_fiat_usd, memo } = data;
const handlePayMyself = () => Linking.openURL(data.deeplink_url);
const handleShare = () => Share.share({
message: `Pay me $${amount_fiat_usd}: ${data.shareable_url}`,
});
return (
<Screen>
<Heading mono>${amount_fiat_usd}</Heading>
{memo && <Subtitle>{memo}</Subtitle>}
<TectonicQrCode value={data.shareable_url} size={200} errorCorrectionLevel="L" />
<Caption mono>{data.shareable_url}</Caption>
<TectonicItemCard
leadingIcon={lightningIcon}
title="Pay it myself"
subtitle="Opens Cash App"
onPress={handlePayMyself}
/>
<TectonicItemCard
leadingIcon={shareIcon}
title="Share with someone"
subtitle="iMessage, WhatsApp, SMS…"
onPress={handleShare}
/>
<BottomRow>
<Link onPress={() => Clipboard.setString(data.shareable_url)}>Copy link</Link>
<Separator />
<Link onPress={() => router.dismiss()}>Done</Link>
</BottomRow>
</Screen>
);
}Linking.openURL(data.deeplink_url) opens the /go variant in Safari/Chrome, which 302-redirects to cash.app/launch/lightning/... and into the Cash App app via the OS URI handler. The browser bounce is brief (one redirect) and unavoidable since Pay Links don't expose a per-link cash.app deeplink — Cash App only sees a freshly-minted invoice per click.
Footgun warning (from Flashnet's deeplink best practices): the URL must be navigated to, not fetched.
Linking.openURL(url)andwindow.location.href = urlboth perform navigation.fetch(url)silently downloads the HTML response and Cash App never opens. If a future change refactors this code path, the navigation semantics must be preserved.
When a user taps a LIGHTNING_RECEIVED row in the wallet activity feed, the tap dispatches to a Lightning-specific detail screen. Modelled on mobile-rn/domains/wallet/transfers/screens/BankTransferDetailsScreen.tsx — the closest existing analogue, which fetches rail-specific data via a secondary call to the rail's own endpoint.
Edit mobile-rn/domains/wallet/utils/transactionNavigation.ts to add a case (lines 84–92 currently switch on type):
case WalletTransactionType.LIGHTNING_RECEIVED:
return handleLightningReceived(transaction);Where handleLightningReceived extracts transaction.metadata.payment_request_id and pushes to Paths.main.lightning.received.$id(transaction.id).
Two callers need the PaymentRequest record: (1) the post-create Share/Pay screen (§16), and (2) the "Re-share" CTA from the details page (§17.3). A single show action serves both.
Engine route update — engines/zar_lightning_payments/config/routes.rb:
ZarLightningPayments::Engine.routes.draw do
resources :payment_requests, only: %i[show create]
endController action — extends the existing PaymentRequestsController from §9.1:
module ZarLightningPayments
class PaymentRequestsController < ::Api::ApiController
before_action :require_lightning_payment_enrollment, only: %i[show create]
def show
# Authorization-by-scope: the user can only see their own requests.
# 404 if the id doesn't belong to current_user — Rails' default behavior on .find.
@payment_request = current_user.payment_requests.find(params[:id])
end
def create
# ... (existing — see §9.1)
end
private
def require_lightning_payment_enrollment
enrollment = current_user.enrollment('zar_lightning_payments')
unprocessable_content('Not enrolled in Lightning Payments') unless enrollment&.state_enabled?
end
end
endThis relies on a User has_many :payment_requests association — needs adding in main app's app/models/user.rb (host model is already extended for other associations like enrollments, so this is one line).
Jbuilder — engines/zar_lightning_payments/app/views/zar_lightning_payments/payment_requests/show.json.jbuilder:
json.data do
json.id @payment_request.id
json.amount_fiat_usd @payment_request.amount_fiat_usd.to_s
json.memo @payment_request.memo
json.shareable_url @payment_request.shareable_url
json.deeplink_url @payment_request.deeplink_url
json.created_at @payment_request.created_at.iso8601
endThe create.json.jbuilder should be revised to render the same shape — call into the show template via json.partial! so creation and fetch return identical payloads. Standard jbuilder pattern.
URL becomes GET /api/v1/lightning_payments/payment_requests/:id.
mobile-rn/domains/wallet/lightning/screens/LightningReceivedDetailsScreen.tsx — composes the standard detail-page primitives (TectonicScaffold, DetailHeader, DetailCard) used by BankTransferDetailsScreen:
export function LightningReceivedDetailsScreen({ transactionId }: { transactionId: string }) {
const { data: tx, isLoading: txLoading } = useTransaction(transactionId);
const paymentRequestId = tx?.metadata?.payment_request_id;
const { data: pr } = usePaymentRequest(paymentRequestId, { enabled: !!paymentRequestId });
if (txLoading || !tx) return <Loading />;
const receivedAmount = formatUsdc(tx.value.atomic);
return (
<TectonicScaffold title="Transaction" headerMode="compact">
<ScrollView>
<DetailHeader
icon="cashNote"
title={receivedAmount}
subtitle="Received via Cash App"
date={formatDate(tx.posted_at)}
/>
<DetailCard
fields={[
tx.metadata.memo && { type: 'row', label: 'Memo', value: tx.metadata.memo },
{ type: 'row', label: 'Source', value: 'Lightning (Cash App)' },
].filter(Boolean)}
/>
{/* 1:1 USD→USDC under our Flashnet invoiced-billing arrangement — no
"requested vs received" delta to surface; fees are billed to ZAR
monthly and never deducted from the user's amount. */}
{pr && (
<DetailCard
fields={[
{
type: 'buttonRow',
label: 'This link can be paid again — share it to receive more.',
buttonText: 'Re-share request',
onPress: () => router.push(Paths.main.lightning.share.$id(pr.id)),
},
]}
/>
)}
<DetailCard
fields={[
{ type: 'row', label: 'Transaction ID', value: tx.id, copyable: true },
]}
/>
</ScrollView>
</TectonicScaffold>
);
}The re-share CTA is meaningful because links are persistent — a user who just received a payment can tap it to keep collecting from the same link.
mobile-rn/app/routes/(main)/lightning/received/[transactionId].tsx— thin Expo Router file that pullstransactionIdfrom params and renders the screen.mobile-rn/domains/wallet/lightning/hooks/usePaymentRequest.ts— TanStack Query hook wrapping the generatedendpointsApiV1LightningPaymentsPaymentRequestsShow(id). Already referenced by §16; this section materializes it.mobile-rn/domains/wallet/hooks/useTransaction.ts— if it doesn't already exist for the wallet activity surface, add it. (Worth confirming: the existing transaction-detail screen probably already has a way to fetch one transaction by id; reuse that.)
- Render the details screen with a stub transaction including
metadata.payment_request_idand a stub PR fetch → assert hero amount, memo, source row, and the re-share CTA are all visible. - Re-share button →
router.pushwas called withPaths.main.lightning.share.$id(pr.id). - No payment_request_id in metadata (defensive) → re-share CTA card is hidden.
Edit mobile-rn/app/features/notifications/utils/notificationRouteMap.ts — add a case for the wallet activity feed (probably already exists), no new route needed since we route the user to the existing activity feed on completion.
If the existing activity-feed route isn't already mapped, add:
case '/wallet/activity': {
return { route: Paths.main.wallet.activity.pathname };
}No deep-link entry needed for inbound Cash App payments — those are processed by Flashnet server-side, not via universal links.
All monetary values flow through Currency::USDCAmount (backend/lib/currency/usdc_amount.rb, atomic-unit-safe, decimals = 6, backed by Solace::Tokens::USDC). The parameterized constructor is the only factory:
Currency::USDCAmount.new(50000000, from: :atomic) # wraps an atomic integer
Currency::USDCAmount.new('49.92', from: :ui) # wraps a UI / decimal string
Currency::USDCAmount.new(49.92, from: :float) # wraps a floatThere is no .from_decimal, .from_atomic, or .from_ui_amount shorthand — use the keyword.
At service boundaries:
- Inbound from controller:
BigDecimal(params[:amount_fiat_usd])— string to decimal, stays aBigDecimaluntil persisted. - Pass to Flashnet (via SDK):
amount_fiat_usd.to_s— Flashnet expects a UI decimal string ("50.00"). - Reading webhook amounts:
order[:amount_out]andorder[:fee_amount]arrive as atomic-unit strings — wrap on assignment:Currency::USDCAmount.new(order[:amount_out], from: :atomic).ui_amount(call.ui_amountonly when writing to a decimal column). - Activity feed
value: pass a typed amount built from the event payload's atomic-unit string:value: Currency::USDCAmount.new(received_amount_atomic, from: :atomic). Thehas_amount :valuemacro onTransaction::Base(line 77) populatesvalue_atomic+value_symbolautomatically.
Per the currency-amounts skill convention: wrap at boundaries, pass typed amounts everywhere else. Never let raw atomic-unit integers travel through more than one service.
Three layers of gating:
- Product Definition
activeflag — globally off until ready. Hard kill-switch. - LightningPaymentEnrollment state — even when the Definition is active, individual users are only
enabledafter seeing the intro screen. The card in Add Dollars only renders whenenrollment.enabled?is true. - Batch enrollment via an admin job — modeled on
app/jobs/admin/backfill_contact_signals_from_kyc_job.rb. SubclassesBatchOperationJob, which providesdry_run+ per-item error handling + stats logging for free. Triggered from the admin UI (Admin::*Job.perform_later(dry_run: false)) or the console.
backend/app/jobs/admin/backfill_lightning_payment_enrollments_job.rb:
module Admin
# Backfills Lightning Payments enrollments to existing customers. Idempotent —
# skips users who already have an enrollment for the zar_lightning_payments
# definition. Mirrors Admin::BackfillContactSignalsFromKycJob's shape.
#
# @example Dry run (default)
# Admin::BackfillLightningPaymentEnrollmentsJob.perform_later
# @example Live run
# Admin::BackfillLightningPaymentEnrollmentsJob.perform_later(dry_run: false)
# @example Scoped to a cohort
# Admin::BackfillLightningPaymentEnrollmentsJob.perform_later(
# dry_run: false, user_ids: %w[uuid-1 uuid-2 ...]
# )
class BackfillLightningPaymentEnrollmentsJob < BatchOperationJob
private
def collection
definition = Product::Definition.find_by!(code: 'zar_lightning_payments')
already_enrolled = Product::Enrollments::LightningPaymentEnrollment
.where(product_definition_id: definition.id)
.select(:user_id)
scope = Customer.where.not(id: already_enrolled)
scope = scope.where(id: options[:user_ids]) if options[:user_ids].present?
scope
end
def process(customer)
definition = Product::Definition.find_by!(code: 'zar_lightning_payments')
return log_progress("Customer #{customer.id}: ineligible") unless definition.eligible?(customer)
definition.issue(customer)
log_progress("Customer #{customer.id}: enrolled")
end
end
endProduction go-live sequence:
- Deploy backend with the
Product::Definitionseededactive: falseand code shipped. - Deploy mobile (the Add Dollars card is gated on
isEligible(), so it stays hidden until enrollments are provisioned). - Flip
Product::Definition#activetotruein production (admin console or aProduct::Definitions::ActivateDefinitionservice call) — keeps Definition active for the platform but no one is enrolled yet. - Run the admin job, scoped to the beta cohort:
Admin::BackfillLightningPaymentEnrollmentsJob.perform_later(dry_run: false, user_ids: [...]). Customers get anavailableenrollment. - Beta users now see the card, walk the intro, and start creating payment requests.
- Monitor CloudWatch metrics (§21) for 48h. Widen the cohort by re-running the job with a larger
user_idslist, or drop the filter entirely once confident.
The BatchOperationJob base class (app/jobs/batch_operation_job.rb) handles dry_run semantics, per-item error logging, and a final stats summary — no need to reinvent that.
Two existing channels:
- CloudWatch Embedded Metric Format (EMF) via the
Observabilitymodule (config/initializers/observability.rb). Application metrics are emitted as EMF-formatted log lines fromObservability::EmfEmitter, picked up by CloudWatch as metrics under theZarCorenamespace. Request-level latency + external-API timings are already instrumented at the middleware level —ApiClient::Services::Flashnetinherits theObservability::ExternalApiInstrumentationautomatically, so per-endpoint Flashnet latencies surface without extra wiring. - Sentry (
config/initializers/sentry.rb) for unhandled exceptions. The webhook controller and the per-transition services allRails.error.report(...)on failure, which routes through Sentry.
Each goes through Observability::EmfEmitter.emit({ ... }) with appropriate dimensions (event name, environment) and a name from the table below. Set up CloudWatch alarms on top.
| Metric name | Type | Alarm threshold |
|---|---|---|
FlashnetWebhookReceived (dim: event) |
count | — (visibility) |
FlashnetWebhookInvalidSignature |
count | > 0 in 5 min |
LightningPaymentCompleted |
count | — |
LightningPaymentFailed |
count | > 0 in 1 hour |
LightningPaymentReceivedPerUser24h |
per-user gauge | > 5000 (anomaly alert, not block) |
Flashnet request latency + error rate are already captured automatically via the request.external_api instrumentation hook (see observability.rb:55-61), surfaced as the ExternalApiTime metric per service. No new emission code needed for those.
Tag Sentry events with flashnet_pay_link_id and payment_request_id where available, so support can pivot from a failed-payment user report to the upstream order in one hop.
ZarFlashnetSdk.create_pay_link— mocked HTTParty; assert request body + idempotency key + error translation.ZarLightningPayments::HandleWebhook::Service— each event type → correct dispatch; verifies no-op fororder.processing/order.confirming/ refunds; callsRecordPaymentReceivedonorder.completedandAlertOnPaymentFailureonorder.failed.ZarLightningPayments::RecordPaymentReceived::Service— emits:lightning_payment_receivedwith the full payload shape from §6.4.ZarLightningPayments::AlertOnPaymentFailure::Service— emits:lightning_payment_failed.LightningPaymentReceivedEventfan-out — verify activity feed entry + push notification are invoked, idempotent on order_id replay.ZarLightningPayments::CreateActivityFeedEntry::Service— idempotent onflashnet_order_id(one row per completed order; multi-payment links → multiple rows), records the delivered amount asvalue(1:1 with the requested USD under invoiced billing), metadata captures the link id + memo for cross-reference.LightningPaymentEnrollment—onboard!advancesavailable → enabled; no tasks created.
POST /api/v1/lightning_payments/payment_requests— happy path → 201 with shareable_url + deeplink_url; not-enrolled user → 422.POST /webhooks/flashnet— valid signature + valid event → 200 + state transition; invalid signature → 401; stale timestamp → 401; duplicate event → 200 with no state change.
- Full happy path: create request → simulate
order.confirmingwebhook (no-op) → simulateorder.completedwithamount_outequal toamount_fiat_usd × 10^6→ activity feed entry exists with that 1:1 amount + order id inprovider_link→ push notification queued. - 1:1 verification (staging): create a real Pay Link for $1.00 via the staging API, pay it from a real Cash App account, assert the receiver's USDC delivery is exactly
1.000000USDC (not0.998XXX— that would mean invoicing isn't enabled on ZAR's key and we're being charged in-band). This is the single most important pre-launch test; everything else depends on the billing arrangement actually being in place. - Multi-payment path: simulate two
order.completedwebhooks with differentorder.idagainst the samepayLinkId→ two activity feed entries created (one per order), both linked to the samepayment_request_idin metadata. - Failure path: simulate
order.failedwebhook →:lightning_payment_failedevent emitted with the order id + error code →AlertSupportOnFailurejob queued; no DB state on the PR changes. - Replay path: re-send a previously processed
order.completed(same order id) → unique constraint onTransaction::LightningReceived.provider_linkprevents duplicate activity feed entry; service returns the existing record.
lightningStore.navigate()— returns correct screen given state combinations.useCreatePaymentRequest— calls the generated endpoint with the right shape.SharePaymentRequestScreen— renders QR + URL; share action invokesShare.share; pay-self invokesLinking.openURLwith the deeplink.
A single end-to-end flow (mobile-rn/.maestro/lightning_payments/request_payment.yaml) covering the entire request-creation journey against staging. This is the canonical pre-launch acceptance test and must pass before promoting the feature to GA.
Flow steps:
- Sign in as a Lightning-Payments-enrolled test user (use the established
signInAsLightningTestUser.yamlsubflow — created as part of this work; mirrors the existingsignInAsKycdUser.yaml). - Open Add Dollars sheet from the Home tab — assert the "Cash App / Lightning" entry is visible (proves enrollment + product registry are wired).
- Tap the Cash App entry.
- First-run intro — assert the value-prop screen renders; tap "Got it" — assert the AsyncStorage flag is set (run the same flow a second time later in the test to verify the intro does NOT reappear).
- New request screen — enter
1.00in the amount field; assert Continue button enables. - Tap Continue — assert the loading state, then the Share/Pay screen renders with: QR code visible, shortened URL visible, "Pay it myself" + "Share" buttons present.
- Verify backend record — call
GET /api/v1/lightning_payments/payment_requests/:id(use Maestro'srunScriptstep with the captured ID) and assertstate: 'open',external_provider_idpresent,shareable_urlmatches. - Tap "Pay it myself" — assert
Linking.openURLfires with aflashnet://orhttps://pay.flashnet.xyz/...deeplink (Maestro captures the intent; we don't need to actually pay). - Dismiss back to Home. Re-open Add Dollars → tap Cash App again → assert we land directly on the New request screen (intro is gone for this user).
- Persistence check — kill and reopen the app, navigate back to a recent payment request via the activity feed (or list endpoint if exposed), assert it loads with the same
external_provider_id.
What's NOT in this E2E flow:
- Actually paying the request from a real Cash App. The 1:1 verification noted above in the backend integration list (§22, "1:1 verification (staging)") is a separate manual pre-launch check — Maestro cannot drive Cash App.
- Webhook receipt → push notification → activity feed entry. Covered by backend integration specs against simulated webhooks; not feasible in Maestro because we'd need a real payment to fire the webhook.
Split-PR protocol. Per [[reference_e2e_split_pr]], the Maestro test ships in a follow-up PR after the backend + mobile work has merged and deployed to staging. The first PR adds the feature; the second adds the Maestro YAML.
File locations:
mobile-rn/.maestro/lightning_payments/request_payment.yaml— main flow.mobile-rn/.maestro/lightning_payments/subflows/signInAsLightningTestUser.yaml— auth helper.- Tagged
@lightning-payments @critical-pathso it runs in the pre-release suite.
| # | Question | Owner | Blocking |
|---|---|---|---|
| 1 | What's the exact refund mechanism on order.refunding? Does the Cash App user receive auto-refund or does ZAR support need to coordinate? |
Awaiting Flashnet | Failure-path consent copy + support runbook |
| 2 | NYC enforcement — how does Flashnet block, and what error surfaces to the recipient? | Awaiting Flashnet | Sender-side UX copy |
| 3 | Global ToS update — fold in Flashnet third-party processing language | Legal | Launch (not dev) |
| 4 | Resolved: no WebhookDedupe exists. With multi-use links, idempotency lives at the persistent record layer — the unique index on Transaction::LightningReceived.provider_link (keyed on flashnet_order_id) prevents duplicate activity feed entries; the push notification's idempotency_key (keyed on order id) is enforced by the unique constraint on firebase_notifications.idempotency_key. Webhook replays naturally no-op at the DB layer. See §6.3, §8.3, §8.4. |
||
| 5 | Resolved: STI subclass Transaction::LightningReceived of the existing Transaction::Base hierarchy. See §8.3. |
||
| 6 | Resolved: standard pattern is isolate_namespace ZarLightningPayments, engine controller ZarLightningPayments::PaymentRequestsController < ::Api::ApiController, engine routes draw resources :payment_requests, host mounts at mount ZarLightningPayments::Engine, at: '/lightning_payments', as: :zar_lightning_payments. URL: /api/v1/lightning_payments/payment_requests. Matches bank_transfers, zar_gold, zar_cash_exchange. See §9.2. |
||
| 7 | Resolved: module Webhook; class WebhookController < ActionController::API; end; end — that's the entire file. No hidden behavior. Walapay convention is to keep webhook controllers in the host app/controllers/webhook/, not inside an engine, so backend/app/controllers/webhook/flashnet_controller.rb (host) rather than inside the engine. See revised §6.2. |
||
| 8 | Resolved: Cash Notes' recovery list exists because cash notes lock funds in escrow — the user must be able to reclaim. Lightning Payments has no escrow; an abandoned link costs nothing. Skip the recovery surface for MVP. | ||
| 9 | Resolved: invoiced billing enabled on ZAR's account Jun 7 2026 (Ethan Marcus, Slack #ext-zarpay-flashnet). Use amountFiatUsd + amountMode: "exact_out" → 1:1 sender↔receiver, fees billed to ZAR at EOM. See References §26. |
Updated Linear issue list reflecting the MVP simplification (no subsidy):
Track A (Discovery, blocks UX copy):
1. Confirm Flashnet refund + NYC enforcement behavior
─ Legal: Update global ToS to cover Flashnet third-party processing
Track B (Backend foundation, parallel):
2. Define zar_lightning_payments Product Definition + LightningPaymentEnrollment
3. Build the ZarFlashnetSdk + ApiClient::Services::Flashnet HTTP client
4. Backend models the PaymentRequest primitive (flat table, Flashnet columns inline)
+ Transaction::LightningReceived STI subclass
Track C (Wired up, sequenced after foundation):
5. Backend creates and manages Flashnet Pay Links [needs 2, 3, 4]
6. Backend processes Flashnet webhooks; emits events; activity feed entry [needs 4]
Track D (Mobile, after Track C):
7. User creates a Lightning payment request; self-pay or share [needs 5]
+ Fees disclosure sheet on New Request screen
Track E (Polish, last):
8. Observability — webhook delivery, payment completion, anomalous volume [needs 6]
Net: down from 7 to 6 issues. The subsidy job, sponsor-account extension, subsidy observability all dropped.
Time estimate (small team, no parallelization tax): ~2 weeks backend, ~1.5 weeks mobile, ~0.5 week observability + cleanup. Total ≈ 4 weeks calendar (down from 5).
backend/ # main app — model + enrollment + SDK + HTTP client + webhook controller + admin + rake
app/models/payment_request.rb
app/models/product/enrollments/lightning_payment_enrollment.rb # alongside MultiSig, VirtualAccount, etc.
app/sdks/zar_flashnet_sdk.rb # mirrors ZarRainSdk pattern
app/sdks/zar_flashnet_sdk/pay_link.rb # Data.define value object
app/controllers/webhook/flashnet_controller.rb # Webhook::FlashnetController, mirrors WalapayController
app/controllers/concerns/flashnet_webhook_secured.rb # HMAC concern, mirrors WalapayWebhookSecured
app/admin/payment_requests.rb # ActiveAdmin for support (cf. app/admin/transactions.rb)
app/admin/transactions/lightning_received.rb # admin view for the new Transaction:: subclass
lib/api_client/services/flashnet.rb # ApiClient::Base subclass (HTTParty)
app/jobs/admin/backfill_lightning_payment_enrollments_job.rb # BatchOperationJob subclass, mirrors backfill_contact_signals_from_kyc_job.rb
db/migrate/YYYYMMDDHHMMSS_create_payment_requests.rb
backend/engines/zar_lightning_payments/ # new isolated-namespace engine
zar_lightning_payments.gemspec # mirrors engines/zar_virtual_account/<gemspec>
lib/zar_lightning_payments.rb # top-level require
lib/zar_lightning_payments/version.rb # VERSION = '0.1.0'
lib/zar_lightning_payments/engine.rb # Rails::Engine subclass, isolate_namespace
lib/zar_lightning_payments/engine_utils.rb # config/initializers loader (matches existing engines)
config/routes.rb # Engine.routes.draw { resources :payment_requests, only: %i[create] }
config/schemas/endpoints/api/zar_lightning_payments/payment_requests/create.yaml
config/locales/en.yml # Rails auto-discovers
app/controllers/zar_lightning_payments/payment_requests_controller.rb # < ::Api::ApiController
app/events/lightning_payment_received_event.rb # Servus::Event subclass; event_name :lightning_payment_received
app/events/lightning_payment_failed_event.rb # Servus::Event subclass; event_name :lightning_payment_failed
app/models/transaction/lightning_received.rb # Transaction::Base STI subclass
app/services/zar_lightning_payments/create_payment_request/service.rb
app/services/zar_lightning_payments/handle_webhook/service.rb
app/services/zar_lightning_payments/record_payment_received/service.rb
app/services/zar_lightning_payments/alert_on_payment_failure/service.rb # emits the failure event
app/services/zar_lightning_payments/create_activity_feed_entry/service.rb
app/services/zar_lightning_payments/send_received_push_notification/service.rb
app/services/zar_lightning_payments/alert_support_on_failure/service.rb # invoked by the failure event
app/views/zar_lightning_payments/payment_requests/create.json.jbuilder
app/views/zar_lightning_payments/payment_requests/show.json.jbuilder
spec/factories/ # FactoryBot factories for the new model
spec/...
mobile-rn/
app/routes/(main)/lightning/value-proposition.tsx # after adding, run `npm run routes:generate`
app/routes/(main)/lightning/new.tsx
app/routes/(main)/lightning/share/[id].tsx
app/routes/(main)/lightning/received/[transactionId].tsx
domains/wallet/lightning/lightningStore.ts
domains/wallet/lightning/index.ts
domains/wallet/lightning/hooks/useCreatePaymentRequest.ts
domains/wallet/lightning/hooks/usePaymentRequest.ts
domains/wallet/lightning/screens/LightningValuePropositionScreen.tsx
domains/wallet/lightning/screens/NewPaymentRequestScreen.tsx
domains/wallet/lightning/screens/SharePaymentRequestScreen.tsx
domains/wallet/lightning/screens/LightningReceivedDetailsScreen.tsx
backend/ # host app
Gemfile # add: gem 'zar_lightning_payments', path: 'engines/zar_lightning_payments'
config/credentials/{development,staging,production}.yml.enc # add flashnet: { server_key, client_key, webhook_signing_secret, base_url }
config/initializers/api_clients.rb # configure ApiClient::Services::Flashnet
config/initializers/product_enrollment_registrations.rb # register :zar_lightning_payments → Product::Enrollments::LightningPaymentEnrollment
config/openapi.yaml # add POST /api/v1/lightning_payments/payment_requests for mobile codegen
config/routes/api.rb # mount ZarLightningPayments::Engine, at: '/lightning_payments', as: :zar_lightning_payments (inside api/v1 namespace)
config/routes/webhook.rb # post 'flashnet/default' → Webhook::FlashnetController#process_default
db/seeds/03_product_definitions.rb # add the zar_lightning_payments definition (seeds live in host, not engine)
mobile-rn/
app/api/generated/** # regenerated via `npm run api:generate` after openapi.yaml changes
app/features/notifications/utils/notificationRouteMap.ts # add /wallet/activity case if absent
config/generated/paths.generated.ts # auto-generated by `npm run routes:generate`
domains/wallet/components/AddMoneyBottomSheet.tsx # add the third TectonicItemCard
domains/wallet/types/transaction.types.ts # add LIGHTNING_RECEIVED enum value
domains/wallet/utils/transactionDisplayMapper.ts # add icon mapping for LIGHTNING_RECEIVED
domains/wallet/utils/transactionNavigation.ts # add LIGHTNING_RECEIVED dispatch case → handleLightningReceived
app/models/user.rb (host) # has_many :payment_requests association
- Flashnet docs index: https://docs.flashnet.xyz/llms.txt
- Onramp endpoint: https://docs.flashnet.xyz/products/orchestration/onramp
- Pay Links endpoint: https://docs.flashnet.xyz/products/orchestration/pay-links
- Deeplink best practices: https://docs.flashnet.xyz/products/orchestration/deeplink-best-practices (the navigation-vs-fetch warning + SSE pattern referenced in §16)
- Webhooks reference: https://docs.flashnet.xyz/products/orchestration/webhooks (signature verification, retry semantics)
- Live example UI: https://flashnet-onramp-example.vercel.app — Flashnet's reference implementation of the share/pay screen pattern. Worth comparing against when implementing §16.
- Example source code: https://github.com/flashnetxyz/pay-link-example — Next.js implementation; useful for the SSE proxy pattern if we ever add a web surface.
- Flashnet partner admin (production): https://orchestra.flashnet.xyz/admin — used to create/manage API keys and verify account configuration.
- Slack thread confirming invoiced billing (Jun 7 2026):
#ext-zarpay-flashnet. Ethan Marcus (Flashnet) enabled invoicing on ZAR's account and confirmed the API combo: "you should see it 1:1 withamountFiatUsdandamountMode: 'exact_out'." This is the canonical record that the plan's fee model is grounded in.
These are deliberately out of scope for MVP. Each is a hardening pattern that Lightning Payments would benefit from, but each is also a platform-wide opportunity — we'd want to introduce them as cross-cutting primitives rather than one-offs in this engine. Capturing them here so they're not lost.
Lightning Payments motivation. POST /lightning_payments/payment_requests creates a Pay Link via Flashnet on every call. A mobile retry (network blip, app foregrounded mid-request) would mint a duplicate Pay Link and burn an external_provider_id. The MVP plan dedupes inbound webhooks via DB uniqueness on Transaction::LightningReceived.provider_link — but there's no equivalent on the outbound side.
Pattern. Accept Idempotency-Key request header; persist {key, user_id, response_body, status_code, created_at} keyed by (user_id, key); on replay within TTL, return the stored response without re-invoking the service. Stripe's spec is the canonical reference. Servus-style implementation:
- New controller concern
IdempotentRequestthat wrapsrun_service— on cache hit, render stored response. - Backing model
Idempotency::Record(top-level namespace; not a transaction). - TTL ~24h (matches Stripe) with a Sidekiq sweeper.
Platform applicability. Every endpoint that creates an external-system side effect or moves money would benefit:
POST /digital_cash— creates Cash Notes; today retry-safety relies on client-generated IDs, which has its own footguns.POST /trades— creating a trade against Walapay/Rain; partial failures here are painful.POST /transfers— internal balance moves; mobile retry semantics here are currently "best effort."POST /onrampsandPOST /offramps— anything Walapay-touching.
A platform-level concern + model is the right granularity. Worth its own initiative once we see real-world duplicate-creation incidents (we haven't, yet — but Lightning Payments increases the surface area).
Lightning Payments motivation. Two endpoints worth scrutinizing:
GET /lightning_payments/payment_requests/:id— polled by the share screen to discover received payments. A misbehaving client (or a screen left open in the background) could hammer this.POST /lightning_payments/payment_requests— abuse here means we're creating arbitrary Pay Links against Flashnet's API, which costs us against our rate budget with them.
Pattern. Rack::Attack is already in the Gemfile and configured for auth endpoints (config/initializers/rack_attack.rb). The existing throttle blocks scope by IP for unauthenticated routes; for authenticated API routes we want req.env['warden'].user&.id as the discriminator. Per-route throttles with sensible defaults:
POSTwrite endpoints: 30 req/min per user.GETpolling endpoints: 120 req/min per user.- Return
429 Too Many RequestswithRetry-After.
Platform applicability. We've gotten away with minimal throttling because our user count is small. As we grow:
GET /digital_cash/:short_id#show— public route, polled by the recipient's pre-claim screen. Already noted in past audits.GET /trades/:id— trade status polling during execution.POST /auth/otpandPOST /auth/verify— already partially throttled but worth re-auditing.- Webhook receivers (Walapay, Raincards, Flashnet) — these should accept legitimate provider retries but reject obvious abuse; trickier because the discriminator isn't
current_user.
This is the kind of thing that's invisible until it isn't. A platform-level audit + standardized throttle table (documented in backend/.claude/rules/) is the right shape — not engine-by-engine.
Lightning Payments motivation. When Flashnet sends a payment.completed webhook, we verify the HMAC, insert a Transaction::LightningReceived, and credit the user. If something goes sideways — credit didn't land, push didn't fire, signature unexpectedly failed — we have nothing durable to inspect. The HTTP request is gone; Sentry has a stack trace at best. Replaying a webhook requires asking Flashnet to re-send.
Pattern. Shared WebhookEvent model that every provider's webhook controller persists before dispatching to its service:
WebhookEvent.create!(
provider: 'flashnet',
event_type: payload[:event_type],
external_id: payload[:event_id], # provider's idempotency anchor
raw_payload: payload, # JSONB
raw_headers: request.headers.to_h, # filtered
signature_status: :verified, # or :failed, :missing
status: :received, # → :processed | :failed
received_at: Time.current,
)Service runs, controller updates status + attaches result/error. A Webhooks::Replay::Service can re-enqueue any past event by ID.
Platform applicability. This is the most generally useful of the three. Every webhook integration we ship would benefit:
- Walapay — bank transfer status, on/offramp confirmations.
- Raincards — card auth, settlement, decline events.
- Privy — wallet creation, signing events.
- Helius — Solana transaction confirmations.
- Sumsub — KYC status changes (these we've actually wanted to replay before).
- Flashnet — Lightning Payments (this plan).
Operational benefit: a single admin page listing recent webhooks across providers, with filter + replay buttons. Right now diagnosing webhook issues means tailing Sidekiq logs across multiple jobs. Estimated effort: ~2–3 days for the model, concern, admin page, and replay service. Pays for itself the first time we have an unexplained webhook gap.
Sequencing note. Idempotency keys and webhook auditing share a common architectural ancestor — both want a "durable record of what we did with this external interaction." Worth considering whether they should be designed together (e.g., Idempotency::Record and WebhookEvent both extending an ExternalInteraction base). Not necessarily — webhook events are inbound, idempotency keys gate outbound — but worth thinking through before implementing the second one.