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 payDefining 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
endThen 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 hitPlan 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/webhooksendpoint
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
endSecrets 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:setupIt 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:editBecause 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
endOne-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
endChecking 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
endWebhook 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 --controllersThis 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 — completedFields
| 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:
- Go to Stripe Dashboard → Developers → Webhooks
- Add endpoint:
https://<your-api-domain>/pay/webhooks - Select events:
checkout.session.completedcheckout.session.expiredinvoice.paidinvoice.payment_failedcustomer.subscription.deletedcustomer.subscription.updated
- Copy the signing secret into your Secrets Manager secret
Removing
belt destroy payThen remove the module reference from your environment main.tf files.
License
MIT