Skip to content

Instantly share code, notes, and snippets.

@fractaledmind
Last active June 8, 2026 11:20
Show Gist options
  • Select an option

  • Save fractaledmind/c639ec3367332696a6d72107aa655d19 to your computer and use it in GitHub Desktop.

Select an option

Save fractaledmind/c639ec3367332696a6d72107aa655d19 to your computer and use it in GitHub Desktop.
Lightning Payments — MVP Implementation Plan

Lightning Payments — MVP Implementation Plan

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.


Overview

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.

Reference: simplified user journey

flowchart TD
    A[Add Dollars sheet] -->|tap &quot;Receive Lightning Payments&quot;| 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
Loading

Architecture

  • Product catalog
    • New: zar_lightning_payments Product::Definition; LightningPaymentEnrollment (zero-task, AutoSwap-style intro)
    • Reused: Product::Definition, Product::Enrollment, EnrollmentRegistry
  • Domain model
    • New: PaymentRequest (flat table; Flashnet columns inline; data-only, no instance methods)
    • Reused: ApplicationRecord
  • 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
  • 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::Event subclasses with event_name declaration + inline invoke (per backend CLAUDE.md). Event payload carries the order data; subscribers don't re-fetch from DB.
  • API surface
    • New: POST /api/v1/lightning_payments/payment_requests
    • Reused: Api::ApiController, validate_body, run_service, OpenAPI codegen
  • Mobile state
    • New: lightningStore.ts Zustand store
    • Reused: Mirrors autoSwapStore.ts shape
  • Mobile screens
    • New: Lightning value-prop · new request · share/pay
    • Reused: TectonicItemCard, TectonicQrCode, PrimaryButton, TectonicCurrencyExchangeInput
  • Notifications
    • New: lightning/received notification route
    • Reused: mapNotificationToRoute(), useNotificationRouter

Engine boundary

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::LightningPaymentEnrollment class
  • Routes, schemas, jbuilder views, engine seed for the Product Definition

In main backend (top-level):

  • PaymentRequest model — kept generic so it can grow into a multi-rail primitive
  • ZarFlashnetSdk + ApiClient::Services::Flashnet — SDK + HTTP client pattern (matches ZarRainSdk living at the top despite Rain being used by the zar_compliance engine)
  • 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.


Backend

1. Configuration

1.1 Flashnet API credentials

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.xyz

Accessed 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.

1.2 Initializer

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
end

2. Product Definition + Enrollment

Follows the AutoSwap pattern from [[reference_value_prop_intro_pattern]] — zero Product::Task records; the intro screen advances state via the existing enrollment-next endpoint.

2.1 Definition seed

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
}

2.2 Enrollment class

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
end

2.3 Registry

config/initializers/product_enrollment_registrations.rb:

Product::EnrollmentRegistry.register(
  :zar_lightning_payments,
  Product::Enrollments::LightningPaymentEnrollment
)

3. PaymentRequest model

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.

3.1 Migration

# 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
end

The 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_messageorder.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.

3.2 Model

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"
end

That'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).


4. Flashnet integration — SDK + HTTP client

Two layers, matching the canonical pattern set by ZarRainSdk (backend/app/sdks/zar_rain_sdk.rb):

  • ApiClient::Services::Flashnet — HTTParty-based HTTP client (under lib/api_client/services/). Translates request bodies to JSON, attaches the bearer header, raises typed ApiClient::* errors. No domain knowledge.
  • ZarFlashnetSdk — app-bound SDK module (extend self, no instance state). Wraps the client, returns Servus::Support::Response from every method, translates ApiClient::ApiError into typed ZarFlashnetSdk::* 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.

4.1 HTTP client

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
end

4.2 SDK module

backend/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
end

4.3 PayLink value object

backend/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
end

No 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).


5. Payment Request creation

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
end

No 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.


6. Webhook handler

6.1 HMAC verification concern

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
end

6.2 Controller

Lives 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
end

Route 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.

6.3 Webhook dispatcher

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
end

Idempotency 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.

6.4 Per-event services

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
end

engines/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
end

Idempotency 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.


8. Event classes + notifications

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

8.1 LightningPaymentReceivedEvent

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
end

The 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.

8.2 LightningPaymentFailedEvent

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
end

v1 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.

8.3 Activity feed entry

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
end

engines/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
end

The 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"

Mobile-side rendering

Adding the new transaction subtype requires two mobile edits so the activity feed renders it correctly:

  1. Enum value in mobile-rn/domains/wallet/types/transaction.types.ts:
    export enum WalletTransactionType {
      // ... existing
      LIGHTNING_RECEIVED = 'LIGHTNING_RECEIVED',
    }
  2. Icon mapping in mobile-rn/domains/wallet/utils/transactionDisplayMapper.ts — both getTransactionIcon and getTransactionIconColor — 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.

8.4 Push notification

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
end

Mobile maps path: '/wallet/activity' to the existing activity feed route (see §13).


9. REST API

9.1 Endpoint

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
end

9.2 Engine routes + host mount

Engine route file engines/zar_lightning_payments/config/routes.rb:

ZarLightningPayments::Engine.routes.draw do
  resources :payment_requests, only: %i[create]
end

Host 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_payments

Final 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).

9.3 JSON Schema

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: 255

9.4 Response

The 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."

9.5 OpenAPI

Add the endpoint to config/openapi.yaml. Mobile picks it up via npm run api:generate → generated function appears at ~/api/generated/payment_requests/.


Frontend (mobile-rn)

11. Domain structure

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.)


12. Zustand store

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(),
};

13. Add Dollars sheet integration

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.


14. Value-prop screen

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:
    1. Instant USDC — "Funds land in your wallet within seconds of the sender tapping pay."
    2. Any Lightning app — "Works with Cash App, Strike, Wallet of Satoshi, and more."
    3. 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} />;
}

15. New request screen

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).

Mutation hook + error UX

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."


16. Share / Pay screen

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) and window.location.href = url both 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.


17. Lightning Received transaction details

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.

17.1 Navigation dispatch

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).

17.2 Backend — payment_requests#show endpoint

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]
end

Controller 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
end

This 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
end

The 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.

17.3 Mobile screen

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.

17.4 Route + hook files

  • mobile-rn/app/routes/(main)/lightning/received/[transactionId].tsx — thin Expo Router file that pulls transactionId from params and renders the screen.
  • mobile-rn/domains/wallet/lightning/hooks/usePaymentRequest.ts — TanStack Query hook wrapping the generated endpointsApiV1LightningPaymentsPaymentRequestsShow(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.)

17.5 Test plan addition

  • Render the details screen with a stub transaction including metadata.payment_request_id and a stub PR fetch → assert hero amount, memo, source row, and the re-share CTA are all visible.
  • Re-share button → router.push was called with Paths.main.lightning.share.$id(pr.id).
  • No payment_request_id in metadata (defensive) → re-share CTA card is hidden.

18. Push notification routing

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.


Cross-cutting

19. Currency::Amount handling

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 float

There 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 a BigDecimal until 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] and order[:fee_amount] arrive as atomic-unit strings — wrap on assignment: Currency::USDCAmount.new(order[:amount_out], from: :atomic).ui_amount (call .ui_amount only 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). The has_amount :value macro on Transaction::Base (line 77) populates value_atomic + value_symbol automatically.

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.

20. Feature rollout

Three layers of gating:

  1. Product Definition active flag — globally off until ready. Hard kill-switch.
  2. LightningPaymentEnrollment state — even when the Definition is active, individual users are only enabled after seeing the intro screen. The card in Add Dollars only renders when enrollment.enabled? is true.
  3. Batch enrollment via an admin job — modeled on app/jobs/admin/backfill_contact_signals_from_kyc_job.rb. Subclasses BatchOperationJob, which provides dry_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
end

Production go-live sequence:

  1. Deploy backend with the Product::Definition seeded active: false and code shipped.
  2. Deploy mobile (the Add Dollars card is gated on isEligible(), so it stays hidden until enrollments are provisioned).
  3. Flip Product::Definition#active to true in production (admin console or a Product::Definitions::ActivateDefinition service call) — keeps Definition active for the platform but no one is enrolled yet.
  4. Run the admin job, scoped to the beta cohort: Admin::BackfillLightningPaymentEnrollmentsJob.perform_later(dry_run: false, user_ids: [...]). Customers get an available enrollment.
  5. Beta users now see the card, walk the intro, and start creating payment requests.
  6. Monitor CloudWatch metrics (§21) for 48h. Widen the cohort by re-running the job with a larger user_ids list, 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.

21. Observability

Two existing channels:

  • CloudWatch Embedded Metric Format (EMF) via the Observability module (config/initializers/observability.rb). Application metrics are emitted as EMF-formatted log lines from Observability::EmfEmitter, picked up by CloudWatch as metrics under the ZarCore namespace. Request-level latency + external-API timings are already instrumented at the middleware level — ApiClient::Services::Flashnet inherits the Observability::ExternalApiInstrumentation automatically, 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 all Rails.error.report(...) on failure, which routes through Sentry.

Custom metrics to emit

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.

Sentry context

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.


22. Test plan

Backend unit

  • ZarFlashnetSdk.create_pay_link — mocked HTTParty; assert request body + idempotency key + error translation.
  • ZarLightningPayments::HandleWebhook::Service — each event type → correct dispatch; verifies no-op for order.processing / order.confirming / refunds; calls RecordPaymentReceived on order.completed and AlertOnPaymentFailure on order.failed.
  • ZarLightningPayments::RecordPaymentReceived::Service — emits :lightning_payment_received with the full payload shape from §6.4.
  • ZarLightningPayments::AlertOnPaymentFailure::Service — emits :lightning_payment_failed.
  • LightningPaymentReceivedEvent fan-out — verify activity feed entry + push notification are invoked, idempotent on order_id replay.
  • ZarLightningPayments::CreateActivityFeedEntry::Service — idempotent on flashnet_order_id (one row per completed order; multi-payment links → multiple rows), records the delivered amount as value (1:1 with the requested USD under invoiced billing), metadata captures the link id + memo for cross-reference.
  • LightningPaymentEnrollmentonboard! advances available → enabled; no tasks created.

Backend integration

  • 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.

Backend end-to-end (RSpec request specs)

  • Full happy path: create request → simulate order.confirming webhook (no-op) → simulate order.completed with amount_out equal to amount_fiat_usd × 10^6 → activity feed entry exists with that 1:1 amount + order id in provider_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.000000 USDC (not 0.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.completed webhooks with different order.id against the same payLinkId → two activity feed entries created (one per order), both linked to the same payment_request_id in metadata.
  • Failure path: simulate order.failed webhook → :lightning_payment_failed event emitted with the order id + error code → AlertSupportOnFailure job queued; no DB state on the PR changes.
  • Replay path: re-send a previously processed order.completed (same order id) → unique constraint on Transaction::LightningReceived.provider_link prevents duplicate activity feed entry; service returns the existing record.

Mobile (Jest)

  • lightningStore.navigate() — returns correct screen given state combinations.
  • useCreatePaymentRequest — calls the generated endpoint with the right shape.
  • SharePaymentRequestScreen — renders QR + URL; share action invokes Share.share; pay-self invokes Linking.openURL with the deeplink.

Mobile (Maestro) — full E2E for payment request creation

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:

  1. Sign in as a Lightning-Payments-enrolled test user (use the established signInAsLightningTestUser.yaml subflow — created as part of this work; mirrors the existing signInAsKycdUser.yaml).
  2. Open Add Dollars sheet from the Home tab — assert the "Cash App / Lightning" entry is visible (proves enrollment + product registry are wired).
  3. Tap the Cash App entry.
  4. 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).
  5. New request screen — enter 1.00 in the amount field; assert Continue button enables.
  6. 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.
  7. Verify backend record — call GET /api/v1/lightning_payments/payment_requests/:id (use Maestro's runScript step with the captured ID) and assert state: 'open', external_provider_id present, shareable_url matches.
  8. Tap "Pay it myself" — assert Linking.openURL fires with a flashnet:// or https://pay.flashnet.xyz/... deeplink (Maestro captures the intent; we don't need to actually pay).
  9. 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).
  10. 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-path so it runs in the pre-release suite.

23. Open questions / blockers

# 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 Existing webhook-dedupe utility Backend audit 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 Activity feed model Backend audit Resolved: STI subclass Transaction::LightningReceived of the existing Transaction::Base hierarchy. See §8.3.
6 Engine isolated-namespace routing Backend audit 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 WebhookController base class contents Backend audit 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 "Lost link" recovery UX Product/Brandon 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 Flashnet fee model Awaiting Flashnet 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.

24. Sequencing

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).


25. File map

New files

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

Edited files

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

26. References


27. Future enhancements

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.

27.1 Idempotency keys for write endpoints

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 IdempotentRequest that wraps run_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 /onramps and POST /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).

27.2 Rate limiting on hot endpoints

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:

  • POST write endpoints: 30 req/min per user.
  • GET polling endpoints: 120 req/min per user.
  • Return 429 Too Many Requests with Retry-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/otp and POST /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.

27.3 Webhook event auditing

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment