Project

belt-pay

0.0
The project is in a healthy, maintained state
Belt plugin providing payment collection, subscriptions, and billing management. Ships with Stripe as the default provider. Includes webhook handling, customer provisioning, and a Transaction model for audit logging.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0.2
~> 13.0
= 13.4.2
 Project Readme

belt-pay

Payments and subscriptions for Belt applications via Stripe.

New to Belt Pay? Check out the Getting Started Guide for a complete walkthrough.

Installation

Add to your Gemfile:

gem 'belt-pay'

Then:

bundle install
belt generate pay

Defining Plans (Convention over Configuration)

Declare your plans once — their prices, limits, and feature flags live in code, not scattered across config. Look them up by a symbolic key everywhere else.

# config/initializers/pay.rb (or wherever you boot)
Belt::Pay.plans do
  plan :free do
    name  'Free'
    description 'For small teams getting started'
    limit :projects, 1
    limit :seats,    3
  end

  plan :pro do
    name        'Pro'
    description 'For growing teams'
    featured                                   # highlight this plan in your UI
    price 49,  interval: :month, stripe_price: ENV['STRIPE_PRO_MONTH']
    price 490, interval: :year,  stripe_price: ENV['STRIPE_PRO_YEAR']
    limit :projects, :unlimited
    limit :seats,    :unlimited
    feature :sso, :audit_logs
  end
end

Then use the plans:

# Subscribe by plan key — belt-pay resolves the Stripe price for the interval
customer.subscribe!(plan: :pro, interval: :year)

# Look plans up
Belt::Pay.plan(:pro).amount(interval: :month)   # => 49.0
Belt::Pay.plans.paid                             # => [pro, ...]
Belt::Pay.plans.featured                         # => the :pro plan
Belt::Pay.plans.to_a                             # => [{ key:, name:, prices:, limits:, ... }] for your frontend

# Gate features and enforce limits
customer.on_plan?(:pro)                          # => true
customer.plan_allows?(:sso)                      # => true
customer.within_limit?(:projects, current_count) # => false once the ceiling is hit

Plan API

Method Description
Belt::Pay.plans { ... } Declare plans (block) or read the registry
Belt::Pay.plan(:key) Look up one plan (nil if undeclared)
Belt::Pay.plan_for_price(id) Find the plan owning a Stripe price ID
plan.amount(interval:) Price in whole currency units
plan.amount_cents(interval:) Price in cents
plan.stripe_price_id(interval:) Stripe price ID for an interval
plan.limit(:name) Read a limit (Integer, :unlimited, or nil)
plan.allows?(:name, usage) Is usage under the limit?
plan.includes_feature?(:name) Does the plan unlock a feature?
plan.to_h Serialize for API/frontend

What You Get

From the gem (no generation needed)

# Include in your Customer/User model
class Customer < ActiveItem::Base
  include Belt::Pay::Billable
end

# Ensure a Stripe customer exists
customer.ensure_pay_customer!

# Create a checkout session (one-time or subscription)
Belt::Pay.create_checkout(customer,
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  mode: 'subscription',
  success_url: 'https://app.example.com/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://app.example.com/cancel')

# Subscribe directly (when payment method is already attached)
customer.subscribe!(price_id: 'price_xxx')

# Check subscription status
customer.active_subscription?  # => true

# Customer self-service billing portal
customer.billing_portal_url(return_url: 'https://app.example.com/settings')
# => { url: "https://billing.stripe.com/p/session/..." }

# Collect payment method (returns client_secret for Stripe Elements)
result = customer.create_setup_intent
result.client_secret  # => "seti_xxx_secret_yyy"

# Attach a payment method
customer.attach_payment_method('pm_xxx')

# View payment details
customer.payment_method_details
# => { last4: "4242", brand: "visa", exp_month: 12, exp_year: 2027 }

# Transaction history
customer.transactions
# => [#<Belt::Pay::Transaction type="subscription" status="completed" ...>]

From the generator (belt g pay)

  • Terraform module — Secrets Manager for Stripe keys, IAM policies
  • Lambda entry point — Dedicated webhook Lambda for Stripe events
  • Lambda config — config/lambda/pay_webhooks.yml
  • Schema update — DynamoDB table for transaction audit log
  • Route injection — Adds /pay/webhooks endpoint

Configuration

Environment Variables

Variable Purpose Default
BELT_PAY_SECRET_NAME Secrets Manager secret name for Stripe keys —
BELT_PAY_WEBHOOK_SECRET_NAME Secret name for webhook signing (falls back to BELT_PAY_SECRET_NAME) —
BELT_PAY_MODE test or live test
APP_NAME App name for table naming —
ENVIRONMENT Environment for table naming —

Programmatic Configuration

Belt::Pay.configure do |config|
  config.provider = :stripe                    # Only :stripe for now
  config.secret_name = 'myapp-prod-stripe'     # Secrets Manager secret
  config.table_name_prefix = 'myapp-prod'      # DynamoDB table prefix
end

Secrets Manager Format

The Stripe secret should contain:

{
  "stripe_secret_key": "sk_live_...",
  "stripe_webhook_secret": "whsec_..."
}

Setting Up and Managing Secrets

Belt Pay hooks into Belt through its standard gem-provided Rake task mechanism. The guided happy path uses BELT_ENV to select the environment:

BELT_ENV=dev belt pay:setup

It prompts without echo for the Stripe secret key and webhook signing secret, then writes them directly through the AWS SDK. Leave either prompt blank to preserve its existing value, so you can run setup once for the API key and again after Stripe gives you the webhook signing secret.

You can also pass the environment with standard Rake task-argument syntax:

belt 'pay:setup[dev]'

Rails-like inspection and arbitrary JSON editing remain available for advanced use:

BELT_ENV=dev belt pay:show
EDITOR="code --wait" BELT_ENV=dev belt pay:edit
# Or: belt 'pay:show[dev]' / belt 'pay:edit[dev]'

pay:show prints the complete decrypted JSON to stdout. pay:edit opens it with $VISUAL or $EDITOR, validates that the result is a JSON object, and writes a new Secrets Manager version only when the content changed. Its temporary file is mode 0600 and is removed when the editor closes. All write paths refuse to overwrite a secret that changed while the command was open.

The tasks detect the application name from Terraform, the AWS profile from infrastructure/<env>/belt.rb, and the region from Terraform or AWS environment variables. Custom setups can override those values with APP_NAME, BELT_PAY_SECRET_NAME, AWS_PROFILE, and AWS_REGION:

APP_NAME=my_app BELT_ENV=prod AWS_PROFILE=my-prod-profile belt pay:setup
BELT_PAY_SECRET_NAME=custom-stripe-secret BELT_ENV=prod belt pay:edit

Because pay:show intentionally reveals credentials, avoid redirecting its output to logs or sharing it in chat. The packaged belt-pay secrets:* executable remains as a backward-compatible alias, but the Belt-native tasks are the preferred interface.

Common Patterns

Annual Subscription (Feature Gating)

# In your controller
class SubscriptionsController < BeltController::Base
  def create
    price_id = ENV['STRIPE_ANNUAL_PRICE_ID']  # Created in Stripe Dashboard
    result = current_customer.subscribe!(price_id: price_id, metadata: { plan: 'pro' })
    success_response(subscription_id: result[:subscription_id], status: result[:status])
  end

  def status
    success_response(active: current_customer.active_subscription?)
  end

  def cancel
    current_customer.cancel_subscription!  # Cancels at period end
    success_response(message: 'Subscription will cancel at end of billing period')
  end

  def portal
    result = current_customer.billing_portal_url(return_url: "#{ENV['FRONTEND_URL']}/settings")
    success_response(url: result[:url])
  end
end

One-Time Payment (Product Purchase)

class CheckoutController < BeltController::Base
  def create
    product = Product.find(params['product_id'])

    result = Belt::Pay.create_checkout(current_customer,
      line_items: [{
        price_data: {
          currency: 'usd',
          product_data: { name: product.name },
          unit_amount: product.price_cents
        },
        quantity: 1
      }],
      mode: 'payment',
      success_url: "#{ENV['FRONTEND_URL']}/checkout/success?session_id={CHECKOUT_SESSION_ID}",
      cancel_url: "#{ENV['FRONTEND_URL']}/products")

    success_response(checkout_url: result[:url])
  end
end

Checking Subscription Access (Middleware Pattern)

class ProController < BeltController::Base
  before_action :require_subscription!

  private

  def require_subscription!
    unless current_customer.active_subscription?
      error_response('Pro subscription required', 403)
    end
  end
end

Webhook Events

The gem automatically handles these Stripe webhook events:

Event Behavior
checkout.session.completed Marks pending transaction as completed
checkout.session.expired Marks pending transaction as failed
invoice.paid Records subscription renewal transaction
invoice.payment_failed Logs payment failure
customer.subscription.deleted Logs subscription cancellation
customer.subscription.updated Logs subscription status changes

Customizing Webhook Behavior

Override the webhook controller to add custom logic:

belt g pay --controllers

This generates a controller in your app that inherits from the gem's default. Override individual handler methods as needed.

Transaction Model

Belt::Pay::Transaction lives inside the gem and records all payment activity:

customer.transactions.each do |txn|
  puts "#{txn.type}: #{txn.amount_cents} #{txn.currency} — #{txn.status}"
end
# subscription: 9900 usd — completed
# subscription_renewal: 9900 usd — completed

Fields

Field Description
id UUID primary key
customer_id Your app's customer/user ID
provider Payment provider (stripe)
provider_session_id Stripe checkout session ID
provider_subscription_id Stripe subscription ID
type checkout, subscription, subscription_renewal, refund
status pending, completed, failed, refunded, canceled
amount_cents Amount in cents
currency ISO currency code
metadata Free-form JSON metadata
created_at ISO 8601 timestamp

Terraform Module

After running belt g pay, add the module to your environment's main.tf:

module "pay" {
  source      = "../modules/pay"
  app_name    = var.app_name
  environment = var.environment

  # Set these or update secrets manually after apply
  # stripe_secret_key     = var.stripe_secret_key
  # stripe_webhook_secret = var.stripe_webhook_secret
}

Stripe Dashboard Setup

After deploying, configure your Stripe webhook endpoint:

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Add endpoint: https://<your-api-domain>/pay/webhooks
  3. Select events:
    • checkout.session.completed
    • checkout.session.expired
    • invoice.paid
    • invoice.payment_failed
    • customer.subscription.deleted
    • customer.subscription.updated
  4. Copy the signing secret into your Secrets Manager secret

Removing

belt destroy pay

Then remove the module reference from your environment main.tf files.

License

MIT